Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface

This commit is contained in:
2026-07-02 08:01:15 +02:00
parent 2202bdbaa0
commit e8ea911b80
59 changed files with 1095 additions and 1022 deletions
+18 -6
View File
@@ -45,12 +45,14 @@ Intentional, reasoned choices — an architecture review should honor them, not
them. Revisit only if the stated reason stops holding.
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
(session-JWT hot path, guards, and the Ory REST clients), `admin/` (built-in screens),
`plugin-host/` (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel),
and `ui/` (design-system view-models + menu/chrome); `server.ts`/`config.ts`/`logger.ts`
and the topology-guard `*.test.ts` stay at the root. Tests are co-located (`foo.test.ts`
beside `foo.ts`). Add a new module to the folder that owns its concern rather than to the
root; don't reintroduce a flat tree.
(session-JWT hot path, guards, and the Ory REST clients), `plugin-host/`
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
not `src/` code.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
base request context. It protects the I/O-free hot path on the public, bot-hit landing
(`/`). (Declined twice.)
@@ -62,6 +64,10 @@ them. Revisit only if the stated reason stops holding.
relative `../../src/*` path. These two barrels are the whole author/operator contract
surface; the `src/*` behind them may be refactored freely. Depth-independent and
refactor-stable by design — don't "fix" a `#`-import back to a relative path.
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
therefore **contract-visible** — changing them is a plugin-API break needing a major
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
@@ -131,6 +137,12 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
plugin was built against — bumped by hand on rebuild, **never** the host's
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
host, so `checkApiVersion` can never fire and a breaking change slips through silently.
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- Run the stability reviewer agent after every implementation of something that can be like
a PR. That includes any change pushed directly to master.
Skip this if the changes are purely documentation and/or comments.
+80 -36
View File
@@ -23,7 +23,18 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
**2. Sign in.** Open <http://localhost:3000> and sign in as the seeded admin —
**`admin@plainpages.local` / `admin`**.
**3. Add your first plugin.** The clone is bind-mounted into the container, so a new
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose restart web
```
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu.
See [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
```ts
@@ -60,6 +71,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [routes & handlers](#routes--handlers)
- [landing pages](#the-landing-pages-home--dashboard)
- [RequestContext](#requestcontext)
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
- [nav & permissions](#nav--permissions)
- [versioning](#contract-versioning)
- [conflict rules](#conflict-rules)
@@ -97,8 +109,9 @@ sessions, and access control — and stays out of your domain logic. **Any page
or gated**, so the same foundation serves a purely public site, a fully locked-down internal
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
time, but nothing ties it to internal-only use. The only screens it ships itself are the ones
for running the system: **users, groups, and permissions**. Everything else is a plugin.
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
**Who it's for.** Experienced developers building server-rendered web products — back-office
and operational tools, dashboards, portals, or public sites with a gated area — for their own
@@ -110,11 +123,15 @@ obvious rather than surprising, you're the audience.
**Included vs. what you add.**
- **Included:** themed sign-in / register / reset (Kratos-backed), and the admin screens
for **users, groups, permissions** (users via Kratos, the relationship graph via Keto).
- **You add:** everything domain-specific, as **plugins** — a list page, a form, a
scheduler, a register, a dashboard — built from the same building blocks the built-in
screens use.
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
user & group admin. It's an ordinary plugin, using the privileged
[`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface to reach Ory.
- **You add:** everything else domain-specific, as **plugins** — a list page, a form, a
scheduler, a register, a dashboard — built from the same building blocks the admin plugin uses.
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
@@ -218,9 +235,10 @@ convention) its nav/permission tokens.
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve
first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.)
the `oauth2` provider routes, and `public` (static). Since plugin routes resolve first, a folder
claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation;
`admin` is **not** reserved — the admin screens are themselves a drop-in plugin mounted at `/admin`.)
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
Nothing else references it; the operator stays in control through the central menu override
@@ -288,7 +306,7 @@ mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)),
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
**anonymous** visitor is redirected to `/login` to sign in (same as the built-in admin screens); the
**anonymous** visitor is redirected to `/login` to sign in; the
requested page is preserved as `return_to`, so after signing in they land **back on the page they
asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
@@ -328,7 +346,7 @@ export async function listThings(ctx: RequestContext) {
- **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names
like `"things/edit"` work, and an out-of-bounds name is refused. The template may `include()`
the core building-block partials (app shell, nav tree, data table, …) and its own
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
@@ -412,6 +430,7 @@ interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL;
user: User | null; // { id, email, roles } from the verified session JWT, or null
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
@@ -420,9 +439,10 @@ interface RequestContext {
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref,
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the
global menu — your plugin's nav fragment plus the others and the admin section — already composed,
role-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
them, when that plugin is present) — already composed, role-filtered, and current-marked for this
request (the gated **Dashboard** link is omitted for an
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
`brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the
@@ -432,7 +452,7 @@ state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then o
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
secret and sets the cookie; the plugin never touches it. (See the reference: `examples/plugins/scheduling/`.)
The same shell renders **every** page (the dashboard, the admin screens, your plugin pages, and the
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
login/registration/front pages), so the menu looks identical signed in or out — it just role-filters.
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
sidebar, single column); everything else still renders.
@@ -453,6 +473,36 @@ breaking). `req`/`res` are the raw Node objects and the full escape hatch; readi
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
from the JWT middleware and are `null`/`[]` until a session exists.
### System capabilities (the `ctx.system` surface)
Most plugins fetch their own data from an upstream service they configure ([the scheduling
reference](examples/plugins/scheduling/) points `SCHEDULING_UPSTREAM` at its backend). A **system
plugin** — one that administers *Plainpages' own* identity stack rather than a domain service —
needs the host's Ory admin clients and the instant-revoke hook instead. The host exposes those on
**`ctx.system`**, and re-exports the client types + their error classes from `#plugin-api`:
```ts
interface SystemCapabilities { // every field optional — present only when the host wired it
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
keto?: KetoClient; // relationship read/write (groups, roles)
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
}
```
`ctx.system` is **`undefined` unless the host wired at least one** of these (Kratos/Keto configured,
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
plugin treats every field as optional and **degrades when absent** — the host never fails a request
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`,
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
the screen renders a themed 503.
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
### Nav & permissions
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
@@ -670,8 +720,8 @@ the sidebar brand shows the configured logo (else a default mark), and the theme
theme-switch default.
**One menu, one shell, everywhere.** There is a single menu (`src/ui/chrome.ts`
`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the
admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages.
`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, plugin
pages (the admin plugin's screens included), and the login / registration / recovery / front (`/`) pages.
So it looks identical signed in or out; it just shows fewer items to an anonymous visitor
(only `public` ones, plus a Sign-in link), filtered by the same per-user rule. The sidebar
collapses to a burger on a narrow screen. A page that wants a focused, chrome-free layout
@@ -936,11 +986,11 @@ claims (email, name) come from the Kratos identity. RP-initiated **logout** is w
resumes to Hydra's post-logout redirect — the first-party `POST /logout` still owns ending
the Kratos session + our JWT cookie.
Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`,
`src/admin/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on
the confirmation page — confidential clients), list, and delete. Confidential vs public
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to
Hydra.
Those clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`,
`examples/plugins/admin/admin-clients.ts`, when that plugin is installed): register (Hydra shows the
generated `client_secret` **once**, on the confirmation page — confidential clients), list, and
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
writes go only to Hydra.
## Email
@@ -1296,16 +1346,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
admin/ Built-in admin screens — the only domain screens plainpages ships
admin-users.ts Users: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
admin-groups.ts Groups: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
admin-roles.ts Roles: list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
admin-clients.ts OAuth2 clients: list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
plugin-host/ Plugin discovery, routing, hooks, view resolution + the stable author barrel
plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types)
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
@@ -1313,7 +1357,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
ui/ Design-system view-models + menu/chrome — the building blocks pages render from
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
@@ -1321,12 +1365,12 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite)
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Roles/Clients + confirm bodies)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the reference example onto /app/plugins/scheduling
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav copied into plugins/) and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the plugin reads/writes (stand-in for your real service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
```
+6 -3
View File
@@ -28,16 +28,19 @@ services:
interval: 2s
timeout: 4s
retries: 30
# plugins/ is empty in the image; bind the reference example in as the `scheduling` plugin so the
# browser flow can open its gated /scheduling/shifts page.
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the
# reference plugin present too — else the admin lacks scheduling:read/write and the gated page 403s.
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap:
volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
+1 -1
View File
@@ -25,7 +25,7 @@ function devSession(roles: string[] = []): string {
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no roles) so the gated scheduling/admin nav stays filtered out.
// member (no roles) so the gated scheduling nav stays filtered out.
test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
});
+1
View File
@@ -6,5 +6,6 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of |
| --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
+48
View File
@@ -0,0 +1,48 @@
# Admin — the system-administration plugin
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose restart web
```
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
menu and the screens work immediately.
## What it demonstrates — a *system* plugin
Most plugins fetch their data from an upstream service of their own (see the [scheduling
reference](../scheduling/README.md)). The admin screens instead administer **Plainpages' own identity
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
admin gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
@@ -62,7 +62,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "OAuth2 clients");
assert.equal(all.title, "OAuth2 clients");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "app-00");
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
@@ -74,7 +74,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
const m = buildClientFormModel({ csrfToken: "tok.sig" });
assert.equal(m.shell.title, "Register client");
assert.equal(m.title, "Register client");
assert.equal(m.form.action, "/admin/clients");
assert.equal(m.form.submitLabel, "Register client");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -93,7 +93,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const client = toClientView({ client_id: "c1", client_name: "Acme", redirect_uris: ["https://a/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" });
const plain = buildClientDetailModel({ client });
assert.equal(plain.shell.title, "Acme");
assert.equal(plain.title, "Acme");
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
assert.equal(plain.created, false);
assert.equal(plain.secret, undefined);
@@ -101,5 +101,5 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" });
assert.equal(fresh.created, true);
assert.equal(fresh.secret, "s3cr3t");
assert.equal(fresh.shell.title, "Client registered");
assert.equal(fresh.title, "Client registered");
});
@@ -1,21 +1,13 @@
// Built-in OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
// OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
// apps log in *through* us with (Ory Hydra, the login+consent handlers). A client is an Ory Hydra
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
// register POST renders the new client's detail page (with the one-time secret) directly instead of a
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate admin-only, CSRF-guarded.
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { safeDecode } from "./admin-groups.ts";
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "../http/context.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
@@ -110,12 +102,8 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
export function buildClientsListModel(opts: {
clients: OAuth2Client[];
csrfToken?: string;
menu?: MenuConfig;
nav?: NavNode[];
url: URL | URLSearchParams | string;
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const needle = query.q.toLowerCase();
@@ -128,17 +116,11 @@ export function buildClientsListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
filterBar: listFilterBar(state),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "OAuth2 clients",
user: opts.user ?? null,
}),
table: listTable(rows),
title: "OAuth2 clients",
};
}
@@ -193,12 +175,8 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
export function buildClientFormModel(opts: {
csrfToken?: string;
error?: string;
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
values?: Partial<ClientInput>;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const v = opts.values;
const nameField: FieldConfig = {
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
@@ -208,6 +186,7 @@ export function buildClientFormModel(opts: {
value: v?.scope ?? DEFAULT_SCOPE,
};
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
error: opts.error,
form: {
action: ADMIN_CLIENTS_BASE,
@@ -220,14 +199,7 @@ export function buildClientFormModel(opts: {
scopeField,
submitLabel: "Register client",
},
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "Register client",
user: opts.user ?? null,
}),
title: "Register client",
};
}
@@ -235,39 +207,22 @@ export function buildClientDetailModel(opts: {
client: ClientView;
created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string;
menu?: MenuConfig;
nav?: NavNode[];
secret?: string; // one-time client_secret (confidential clients), shown once right after create
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const base = detailHref(opts.client.id);
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
client: opts.client,
created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
nav: opts.nav ?? [],
secret: opts.secret,
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
csrfToken: opts.csrfToken ?? "",
menu,
title: opts.created ? "Client registered" : opts.client.name,
user: opts.user ?? null,
}),
title: opts.created ? "Client registered" : opts.client.name,
};
}
// ---- request handler (imperative shell) ----
export interface AdminClientsDeps {
csrfSecret: string;
hydra: HydraAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
}
function readClientInput(form: URLSearchParams): ClientInput {
return {
firstParty: form.get("firstParty") === "on",
@@ -278,75 +233,78 @@ function readClientInput(form: URLSearchParams): ClientInput {
};
}
export async function handleAdminClients(ctx: RequestContext, csrfToken: string, deps: AdminClientsDeps): Promise<RouteResult | null> {
const path = ctx.url.pathname;
if (path !== ADMIN_CLIENTS_BASE && !path.startsWith(`${ADMIN_CLIENTS_BASE}/`)) return null;
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { hydra, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderForm = async (extra: { error?: string; values?: Partial<ClientInput> }): Promise<RouteResult> =>
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, nav, user, ...extra }) }) });
const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise<RouteResult> =>
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, nav, user, ...extra }) }) });
const notFound = async (): Promise<RouteResult> => ({ html: await render("404", { title: "Not found" }), status: 404 });
// /admin/clients — list (GET) · register (POST)
if (seg.length === 0) {
if (method === "GET") {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, nav, url: ctx.url, user }) }) };
}
if (method === "POST") {
const input = readClientInput(form!);
const error = validateClientInput(input);
if (error) return { ...(await renderForm({ error, values: input })), status: 400 };
let created: OAuth2Client;
try {
created = await hydra.createClient(clientPayload(input));
} catch (err) {
// A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form;
// a 5xx (Hydra down) rethrows → 500. Mirrors the challenge-handler degrade.
if (err instanceof HydraError && err.status < 500) {
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
}
throw err;
}
// Show the one-time secret now (Hydra never returns it again) — render the detail directly.
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
return renderDetail(created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
}
return null;
}
// /admin/clients/new — register form
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
// /admin/clients/:id …
const id = safeDecode(seg[0]!);
if (id === null) return notFound();
const client = await hydra.getClient(id);
if (!client) return notFound();
const base = detailHref(id);
if (seg.length === 1 && method === "GET") return renderDetail(client);
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
const name = toClientView(client).name;
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken,
menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, nav, title: "Delete client", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
await hydra.deleteClient(id);
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
return { redirect: ADMIN_CLIENTS_BASE };
}
return null;
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, "Hydra OAuth2 admin");
return inner({ ctx, hydra, user });
};
}
// Same, plus the target client from ctx.params.id (unknown → themed 404).
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): RouteHandler {
return withClients(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const client = await deps.hydra.getClient(id);
if (!client) return notFound(deps.ctx);
return inner(deps, client, id);
});
}
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-form" });
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-detail" });
// GET /admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" };
});
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
const input = readClientInput((await guardedForm(ctx))!);
const error = validateClientInput(input);
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
let created: OAuth2Client;
try {
created = await hydra.createClient(clientPayload(input));
} catch (err) {
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
});
// GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})));
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
// GET /admin/clients/:id/delete — the deliberate confirm step.
export const clientsDeleteConfirm = withClient((deps, client, id) => {
const base = detailHref(id);
const name = toClientView(client).name;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client",
message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client",
}) }, view: "confirm" });
});
// POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
await guardedForm(ctx); // CSRF-verify the POST
await hydra.deleteClient(id);
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
return { redirect: ADMIN_CLIENTS_BASE };
});
@@ -14,7 +14,7 @@ import {
memberView,
parseSubject,
} from "./admin-groups.ts";
import type { RelationTuple } from "../auth/keto-client.ts";
import type { RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple =>
@@ -59,7 +59,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "Groups");
assert.equal(all.title, "Groups");
// The group name is the row header, linking to its detail page.
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "team-00");
@@ -78,7 +78,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.shell.title, "New group");
assert.equal(m.title, "New group");
assert.equal(m.form.action, "/admin/groups");
assert.equal(m.form.submitLabel, "Create group");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -103,7 +103,7 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
{ label: "ops (group)", value: "group:ops" },
];
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
assert.equal(m.shell.title, "eng");
assert.equal(m.title, "eng");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
assert.equal(m.add.action, "/admin/groups/eng/members");
@@ -1,22 +1,14 @@
// Built-in Groups admin screen: list / create / delete Keto groups and manage membership.
// Groups admin screen: list / create / delete Keto groups and manage membership.
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
// every member tuple. Pure builders turn tuples + the request URL into view models; `handleAdminGroups`
// is the imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded, mapping each action
// to a RouteResult.
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate admin-only, CSRF-guarded,
// each returning a RouteResult.
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const GROUP_NS = "Group";
const MEMBERS = "members";
@@ -120,12 +112,8 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
export function buildGroupsListModel(opts: {
csrfToken?: string;
groups: GroupView[];
menu?: MenuConfig;
nav?: NavNode[];
url: URL | URLSearchParams | string;
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
@@ -148,17 +136,11 @@ export function buildGroupsListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
filterBar: listFilterBar(state),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "Groups",
user: opts.user ?? null,
}),
table: listTable(rows, state, sort),
title: "Groups",
};
}
@@ -215,17 +197,14 @@ export function buildGroupFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
values?: { member?: string; name?: string };
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const nameField: FieldConfig = {
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
error: opts.error,
form: {
action: ADMIN_GROUPS_BASE,
@@ -236,14 +215,7 @@ export function buildGroupFormModel(opts: {
selectedMember: opts.values?.member ?? "",
submitLabel: "Create group",
},
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "New group",
user: opts.user ?? null,
}),
title: "New group",
};
}
@@ -253,11 +225,7 @@ export function buildGroupDetailModel(opts: {
error?: string;
group: { name: string };
members: MemberView[];
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const name = opts.group.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
@@ -265,32 +233,18 @@ export function buildGroupDetailModel(opts: {
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
error: opts.error,
group: { name },
members: { action: `${base}/members/delete`, rows: opts.members },
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
menu,
title: name,
user: opts.user ?? null,
}),
title: name,
};
}
// ---- request handler (imperative shell) ----
export interface AdminGroupsDeps {
csrfSecret: string;
keto: KetoClient;
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
}
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
@@ -325,86 +279,96 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0;
}
// Decode a path segment without letting malformed %-encoding throw (→ caller treats it as not found).
export function safeDecode(seg: string): string | null {
try { return decodeURIComponent(seg); } catch { return null; }
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
return inner({ ctx, keto, kratosAdmin, user });
};
}
export async function handleAdminGroups(ctx: RequestContext, csrfToken: string, deps: AdminGroupsDeps): Promise<RouteResult | null> {
const path = ctx.url.pathname;
if (path !== ADMIN_GROUPS_BASE && !path.startsWith(`${ADMIN_GROUPS_BASE}/`)) return null;
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withGroups((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { keto, kratosAdmin, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_GROUPS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "group-form" };
};
const renderList = async (): Promise<RouteResult> => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { html: await render("admin/groups", { model: buildGroupsListModel({ csrfToken, groups, menu, nav, url: ctx.url, user }) }) };
};
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(keto, kratosAdmin);
return { html: await render("admin/group-form", { model: buildGroupFormModel({ csrfToken, memberOptions: options, menu, nav, user, ...extra }) }) };
};
const renderDetail = async (name: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
return { html: await render("admin/group-detail", { model: buildGroupDetailModel({ candidates: options, csrfToken, group: { name }, members, menu, nav, user }) }) };
};
// GET /admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, url: ctx.url }) }, view: "groups" };
});
// /admin/groups — list (GET) · create (POST)
if (seg.length === 0) {
if (method === "GET") return renderList();
if (method === "POST") {
const name = (form!.get("name") ?? "").trim();
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
const reject = (error: string): Promise<RouteResult> =>
renderForm({ error, values: { member: form!.get("member") ?? "", name } }).then((r) => ({ ...r, status: 400 }));
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a member to add as the group's first member.");
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name });
return { redirect: detailHref(name) };
}
return null;
}
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
export const groupsCreate = withGroups(async (deps) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = memberTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a member to add as the group's first member.");
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name });
return { redirect: detailHref(name) };
});
// /admin/groups/new — create form
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
// GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
// /admin/groups/:name
const name = safeDecode(seg[0]!);
if (name === null || !isValidGroupName(name)) return { html: await render("404", { title: "Not found" }), status: 404 };
// GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" };
});
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
return { redirect: detailHref(name) };
});
// GET /admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName((deps, name) => {
const base = detailHref(name);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group",
message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group",
}) }, view: "confirm" });
});
if (seg.length === 1 && method === "GET") return renderDetail(name);
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
await guardedForm(ctx); // CSRF-verify the POST
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
return { redirect: ADMIN_GROUPS_BASE };
});
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
// Skip an invalid member or a self-nest (the picker already excludes both).
if (tuple && tuple.subject_set?.object !== name) await keto.writeTuple(tuple);
return { redirect: base };
}
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group", csrfToken,
menu, message: `Delete group ${name}? This removes the group and all its memberships.`, nav, title: "Delete group", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
return { redirect: ADMIN_GROUPS_BASE };
}
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
const tuple = memberTuple(name, (form!.get("member") ?? "").trim());
if (tuple) await keto.deleteTuple(tuple);
return { redirect: base };
}
return null;
}
// POST /admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
if (tuple) await keto.deleteTuple(tuple);
return { redirect: detailHref(name) };
});
@@ -14,7 +14,7 @@ import {
isValidRoleName,
roleMemberTuple,
} from "./admin-roles.ts";
import type { ExpandTree, RelationTuple } from "../auth/keto-client.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple =>
@@ -57,7 +57,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "Roles");
assert.equal(all.title, "Roles");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "role-00");
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
@@ -73,7 +73,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.shell.title, "New role");
assert.equal(m.title, "New role");
assert.equal(m.form.action, "/admin/roles");
assert.equal(m.form.submitLabel, "Create role");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -96,7 +96,7 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
assert.equal(m.shell.title, "admin");
assert.equal(m.title, "admin");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
assert.equal(m.add.action, "/admin/roles/admin/members");
@@ -1,14 +1,15 @@
// Built-in Roles & permissions admin screen: list / create / delete Keto roles and assign
// Roles & permissions admin screen: list / create / delete Keto roles and assign
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
// are reused from admin-groups. The role-specific piece is the **effective access** view:
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
// Kratos is read only to label members. `handleAdminRoles` is the imperative shell app.ts dispatches
// to — gated admin-only, CSRF-guarded.
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate admin-only, CSRF-guarded.
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
@@ -19,18 +20,8 @@ import {
memberView,
pagedTuples,
parseSubject,
safeDecode,
} from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { ExpandTree, KetoClient, RelationTuple } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const ROLE_NS = "Role";
const MEMBERS = "members";
@@ -105,13 +96,9 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
export function buildRolesListModel(opts: {
csrfToken?: string;
menu?: MenuConfig;
nav?: NavNode[];
roles: RoleView[];
url: URL | URLSearchParams | string;
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
@@ -134,17 +121,11 @@ export function buildRolesListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
filterBar: listFilterBar(state),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "Roles",
user: opts.user ?? null,
}),
table: listTable(rows, state, sort),
title: "Roles",
};
}
@@ -201,17 +182,14 @@ export function buildRoleFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
menu?: MenuConfig;
nav?: NavNode[];
user?: User | null;
values?: { member?: string; name?: string };
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const nameField: FieldConfig = {
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
error: opts.error,
form: {
action: ADMIN_ROLES_BASE,
@@ -222,14 +200,7 @@ export function buildRoleFormModel(opts: {
selectedMember: opts.values?.member ?? "",
submitLabel: "Create role",
},
nav: opts.nav ?? [],
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "New role",
user: opts.user ?? null,
}),
title: "New role",
};
}
@@ -239,51 +210,32 @@ export function buildRoleDetailModel(opts: {
effective: EffectiveUser[];
error?: string;
members: MemberView[];
menu?: MenuConfig;
nav?: NavNode[];
role: { name: string };
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const name = opts.role.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
nav: opts.nav ?? [],
role: { name },
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
menu,
title: name,
user: opts.user ?? null,
}),
title: name,
};
}
// ---- request handler (imperative shell) ----
export interface AdminRolesDeps {
csrfSecret: string;
keto: KetoClient;
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke: assigning/unassigning a *user* kills their live tokens
}
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
if (deps.revoke && member.startsWith("user:")) deps.revoke(member.slice("user:".length));
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
}
// A role exists exactly while it has ≥1 member (Keto has no create-object).
@@ -302,95 +254,114 @@ async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolea
.sort((a, b) => a.label.localeCompare(b.label));
}
export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, deps: AdminRolesDeps): Promise<RouteResult | null> {
const path = ctx.url.pathname;
if (path !== ADMIN_ROLES_BASE && !path.startsWith(`${ADMIN_ROLES_BASE}/`)) return null;
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { keto, kratosAdmin, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_ROLES_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
return { html: await render("admin/roles", { model: buildRolesListModel({ csrfToken, menu, nav, roles, url: ctx.url, user }) }) };
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
const renderForm = async (extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(keto, kratosAdmin);
return { html: await render("admin/role-form", { model: buildRoleFormModel({ csrfToken, memberOptions: options, menu, nav, user, ...extra }) }) };
};
const renderDetail = async (name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const tuples = await pagedTuples(keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(keto, name, tuples.length > 0, emailById);
const html = await render("admin/role-detail", { model: buildRoleDetailModel({ candidates: options, csrfToken, effective, members, menu, nav, role: { name }, user, ...(error ? { error } : {}) }) });
return error ? { html, status: 400 } : { html };
};
// /admin/roles — list (GET) · create (POST)
if (seg.length === 0) {
if (method === "GET") return renderList();
if (method === "POST") {
const name = (form!.get("name") ?? "").trim();
const member = (form!.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
const reject = (error: string): Promise<RouteResult> =>
renderForm({ error, values: { member, name } }).then((r) => ({ ...r, status: 400 }));
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a user or group to assign the role to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(deps, member);
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
return { redirect: detailHref(name) };
}
return null;
}
// /admin/roles/new — create form
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
// /admin/roles/:name …
const name = safeDecode(seg[0]!);
if (name === null || !isValidRoleName(name)) return { html: await render("404", { title: "Not found" }), status: 404 };
const base = detailHref(name);
if (seg.length === 1 && method === "GET") return renderDetail(name);
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
const member = (form!.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); } // the picker only offers real users/groups
return { redirect: base };
}
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
// Self-protection: deleting the admin role removes everyone's admin — refuse it outright.
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role", csrfToken,
menu, message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, nav, title: "Delete role", user,
}) }) };
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
// a whole-role delete drops many members at once — left to lag like a group change; the
// per-member unassign above is the instant-revoke path.
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE };
}
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
const member = (form!.get("member") ?? "").trim();
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
// Admin held only via a group isn't covered here — the robust "last effective admin" check is deferred.
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
return { redirect: base };
}
return null;
}
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
};
// The role detail (members + effective access). With `error` set it's a 400 (a rejected action).
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
return error ? { ...result, status: 400 } : result;
};
// GET /admin/roles — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
});
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a user or group to assign the role to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(revoke, member);
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
return { redirect: detailHref(name) };
});
// GET /admin/roles/new — the create form.
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
return { redirect: detailHref(name) };
});
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
const base = detailHref(name);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
}) }, view: "confirm" });
});
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
// documented instant-revoke tradeoff; the admin role is protected).
export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE };
});
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
// covered — the robust "last effective admin" check is deferred).
export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
return { redirect: detailHref(name) };
});
@@ -0,0 +1,70 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
// ---- nav fragment ----
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
});
// ---- auth gates ----
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
});
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
const post = (over: { body?: string; verifyCsrf?: (s: string | null | undefined) => boolean }) => fakeCtx({ method: "POST", ...over });
const ok = await guardedForm(post({ body: "_csrf=tok&name=Bo", verifyCsrf: () => true }));
assert.equal(ok?.get("name"), "Bo");
await assert.rejects(guardedForm(post({ body: "_csrf=nope&name=Bo", verifyCsrf: () => false })), // ctx.verifyCsrf rejects
(e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(await guardedForm(fakeCtx({ method: "GET" })), undefined); // not a mutation → no gate, no body read
});
// ---- confirm-page model ----
test("buildConfirmModel wires the danger action, message, breadcrumbs and title (shell comes from ctx.chrome)", () => {
const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
message: "Delete ada@x.io?", title: "Delete user",
});
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
});
+78
View File
@@ -0,0 +1,78 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_ROLES_BASE = "/admin/roles";
export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
export const ADMIN_NAV: NavNode = {
children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
],
icon: "i-shield",
id: "admin",
label: "Admin",
permission: ADMIN_PERMISSION,
};
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return user;
}
// Read + CSRF-verify a mutation's form body once (double-submit via ctx.verifyCsrf); non-POST ⇒
// undefined. A POST without a valid token is refused (GuardError → 403).
export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
return form;
}
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
export function notFound(ctx: RequestContext): RouteResult {
return { data: { chrome: ctx.chrome, message: "That item doesn't exist.", title: "Not found" }, status: 404, view: "notice" };
}
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
export function unavailable(ctx: RequestContext, what: string): RouteResult {
return { data: { chrome: ctx.chrome, message: `${what} is not configured on this deployment.`, title: "Admin unavailable" }, status: 503, view: "notice" };
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
message: string;
title: string;
}) {
return {
breadcrumbs: opts.breadcrumbs,
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
title: opts.title,
};
}
@@ -1,7 +1,8 @@
// Built-in Users admin screen: the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts.
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Identity } from "#plugin-api";
import {
buildUserFormModel,
buildUsersListModel,
@@ -10,7 +11,6 @@ import {
toUserView,
updateIdentityPayload,
} from "./admin-users.ts";
import type { Identity } from "../auth/kratos-admin.ts";
const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
@@ -41,7 +41,7 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.shell.title, "Users");
assert.equal(all.title, "Users");
// Search narrows to one and shows a pill.
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
@@ -64,7 +64,7 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
const m = buildUserFormModel({ csrfToken: "tok.sig" });
assert.equal(m.shell.title, "New user");
assert.equal(m.title, "New user");
assert.equal(m.form.action, "/admin/users");
assert.equal(m.form.submitLabel, "Create user");
assert.equal(m.form.csrfToken, "tok.sig");
@@ -76,7 +76,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
const m = buildUserFormModel({ identity: identity(3) });
assert.equal(m.shell.title, "Edit user");
assert.equal(m.title, "Edit user");
assert.equal(m.form.action, `/admin/users/${id(3)}`);
assert.equal(m.form.submitLabel, "Save changes");
const email = m.form.fields.find((f) => f.name === "email")!;
@@ -1,20 +1,11 @@
// Built-in Users admin screen: list Kratos identities (filter/sort/paginate) +
// Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
// models; `handleAdminUsers` is the imperative shell app.ts dispatches to — gated admin-only,
// CSRF-guarded, each action mapped to a RouteResult (render, or redirect after a write — PRG).
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { safeDecode } from "./admin-groups.ts";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
import { KratosError } from "../auth/kratos-public.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
@@ -118,12 +109,8 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
export function buildUsersListModel(opts: {
csrfToken?: string;
identities: Identity[];
menu?: MenuConfig;
nav?: NavNode[]; // the unified global menu (ctx.chrome.nav); the host builds it once per request
url: URL | URLSearchParams | string;
user?: User | null;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
@@ -146,17 +133,11 @@ export function buildUsersListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
filterBar: listFilterBar(state, all.length),
nav: opts.nav ?? [],
pagination: listPagination(state, page),
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: "Users",
user: opts.user ?? null,
}),
table: listTable(rows, state, sort),
title: "Users",
};
}
@@ -238,13 +219,9 @@ export function buildUserFormModel(opts: {
csrfToken?: string;
error?: string;
identity?: Identity | null;
menu?: MenuConfig;
nav?: NavNode[];
recovery?: RecoveryCode;
user?: User | null;
values?: Partial<UserInput>;
}) {
const menu = opts.menu ?? DEFAULT_MENU;
const editing = opts.identity != null;
const view = editing ? toUserView(opts.identity!) : null;
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
@@ -260,6 +237,7 @@ export function buildUserFormModel(opts: {
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
edit: editing ? {
deleteAction: `${idPath}/delete`,
id: view!.id,
@@ -270,28 +248,13 @@ export function buildUserFormModel(opts: {
} : undefined,
error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
nav: opts.nav ?? [],
recovery: opts.recovery,
shell: buildShellContext({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
csrfToken: opts.csrfToken ?? "",
menu,
title: editing ? "Edit user" : "New user",
user: opts.user ?? null,
}),
title: editing ? "Edit user" : "New user",
};
}
// ---- request handler (imperative shell) ----
export interface AdminUsersDeps {
csrfSecret: string;
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke: kill the target's live tokens on deactivate/delete
}
function readUserInput(form: URLSearchParams): UserInput {
return {
email: (form.get("email") ?? "").trim(),
@@ -301,103 +264,113 @@ function readUserInput(form: URLSearchParams): UserInput {
};
}
// Handle a request under /admin/users. Returns null when the path isn't ours (app.ts falls
// through to its 404). Throws GuardError for auth/CSRF failures (app.ts maps it to a response).
export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, deps: AdminUsersDeps): Promise<RouteResult | null> {
const path = ctx.url.pathname;
if (path !== ADMIN_USERS_BASE && !path.startsWith(`${ADMIN_USERS_BASE}/`)) return null;
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
const { kratosAdmin, menu, render } = deps;
const nav = ctx.chrome.nav; // the one global menu, role-filtered + current-marked by the host
const method = (ctx.req.method ?? "GET").toUpperCase();
const seg = path.slice(ADMIN_USERS_BASE.length).split("/").filter(Boolean);
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
const renderList = async (): Promise<RouteResult> => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { html: await render("admin/users", { model: buildUsersListModel({ csrfToken, identities, menu, nav, url: ctx.url, user }) }) };
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, "Kratos identity admin");
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
};
const renderForm = async (extra: Parameters<typeof buildUserFormModel>[0]): Promise<RouteResult> =>
({ html: await render("admin/user-form", { model: buildUserFormModel({ csrfToken, menu, nav, user, ...extra }) }) });
// /admin/users — list (GET) · create (POST)
if (seg.length === 0) {
if (method === "GET") return renderList();
if (method === "POST") {
const input = readUserInput(form!);
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
if (err instanceof KratosError) return { ...(await renderForm({ error: createError(err), values: input })), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
return { redirect: ADMIN_USERS_BASE };
}
return null;
}
// /admin/users/new — create form
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
// /admin/users/:id …
const targetId = safeDecode(seg[0]!); // malformed %-encoding → 404, not a 500 (matches groups/roles/clients)
if (targetId === null) return { html: await render("404", { title: "Not found" }), status: 404 };
const identity = await kratosAdmin.getIdentity(targetId);
if (!identity) return { html: await render("404", { title: "Not found" }), status: 404 };
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(targetId)}`;
if (seg.length === 1) {
if (method === "GET") return renderForm({ identity });
if (method === "POST") {
try {
await kratosAdmin.updateIdentity(targetId, updateIdentityPayload(identity, readUserInput(form!)));
} catch (err) {
if (err instanceof KratosError) return { ...(await renderForm({ error: "Could not save changes — check the fields and try again.", identity })), status: 400 };
throw err;
}
return { redirect: back };
}
return null;
}
if (seg.length === 2) {
const isSelf = targetId === user.id; // self-protection: an admin must not lock themselves out
if (seg[1] === "delete" && method === "GET") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
const view = toUserView(identity);
return { html: await render("admin/confirm", { model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user", csrfToken,
menu, message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, nav, title: "Delete user", user,
}) }) };
}
if (method === "POST") {
if (seg[1] === "state") {
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
if (nextState === "inactive") deps.revoke?.(targetId); // a deactivation takes effect now, not after the JWT TTL
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId });
return { redirect: back };
}
if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // the account is gone — reject its live tokens immediately
ctx.log.info("admin: user deleted", { actor: user.id, target: targetId });
return { redirect: ADMIN_USERS_BASE };
}
if (seg[1] === "recovery") {
const recovery = await kratosAdmin.createRecoveryCode(targetId);
return renderForm({ identity, recovery });
}
}
}
return null;
}
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler {
return withUser(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const identity = await deps.kratosAdmin.getIdentity(id);
if (!identity) return notFound(deps.ctx);
return inner(deps, identity, id);
});
}
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "user-form" });
// GET /admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, url: ctx.url }) }, view: "users" };
});
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err), values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
return { redirect: ADMIN_USERS_BASE };
});
// GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
// GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: "Could not save changes — check the fields and try again.", identity }), status: 400 };
throw err;
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return { ...formResult(ctx, { error: "You can't deactivate your own account.", identity }), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id);
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: id });
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: "You can't delete your own account.", identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user",
message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user",
}) }, view: "confirm" });
});
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return { ...formResult(ctx, { error: "You can't delete your own account.", identity }), status: 400 };
await kratosAdmin.deleteIdentity(id);
revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
return { redirect: ADMIN_USERS_BASE };
});
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
const recovery = await kratosAdmin.createRecoveryCode(id);
return formResult(ctx, { identity, recovery });
});
function createError(err: KratosError): string {
return err.status === 409
? "A user with that email already exists."
+65
View File
@@ -0,0 +1,65 @@
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system.
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
//
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
routes: [
// Users
r("GET", "/users", usersList),
r("POST", "/users", usersCreate),
r("GET", "/users/new", usersNewForm),
r("GET", "/users/:id", usersEditForm),
r("POST", "/users/:id", usersUpdate),
r("POST", "/users/:id/state", usersState),
r("GET", "/users/:id/delete", usersDeleteConfirm),
r("POST", "/users/:id/delete", usersDelete),
r("POST", "/users/:id/recovery", usersRecovery),
// Groups
r("GET", "/groups", groupsList),
r("POST", "/groups", groupsCreate),
r("GET", "/groups/new", groupsNewForm),
r("GET", "/groups/:name", groupsDetail),
r("POST", "/groups/:name/members", groupsAddMember),
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles
r("GET", "/roles", rolesList),
r("POST", "/roles", rolesCreate),
r("GET", "/roles/new", rolesNewForm),
r("GET", "/roles/:name", rolesDetail),
r("POST", "/roles/:name/members", rolesAddMember),
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
r("POST", "/roles/:name/delete", rolesDelete),
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
// OAuth2 clients
r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate),
r("GET", "/clients/new", clientsNewForm),
r("GET", "/clients/:id", clientsDetail),
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
r("POST", "/clients/:id/delete", clientsDelete),
],
});
@@ -0,0 +1,17 @@
<%#
OAuth2 client detail page: the client-detail body (info · one-time secret · delete) in the
shell. Doubles as the post-register page when `created`/`secret` are set.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
OAuth2 client register page: the client-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,8 +1,8 @@
<%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (src/admin/admin-clients.ts).
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
@@ -11,11 +11,11 @@
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+17
View File
@@ -0,0 +1,17 @@
<%#
Admin destructive-action confirmation page: the confirm body in the app shell. Model
from buildConfirmModel: { message, confirm:{action,label}, cancelHref, nav, shell }.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/confirm-body", { cancelHref: model.cancelHref, confirm: model.confirm, csrfToken: chrome.csrfToken, message: model.message });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,16 +1,16 @@
<%#
Group admin detail / membership page: the group-detail body in the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
Group admin create page: the group-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,8 +1,8 @@
<%#
Groups admin list: the same building blocks as the Users screen, around the shell, but
backed by live Keto subject sets (src/admin/admin-groups.ts). Filter/sort/page round-trip the URL.
backed by live Keto subject sets (admin-groups.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
@@ -11,11 +11,11 @@
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+17
View File
@@ -0,0 +1,17 @@
<%#
Admin notice page — a single message in the app shell, reused for not-found (404) and
capability-unavailable (503). The shell renders `title` as the page <h1>; the body is one line.
Data: chrome, title, message.
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/notice-body", { message });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
csrfToken: chrome.csrfToken,
nav: navHtml,
theme: chrome.theme,
title,
user: chrome.user,
}) %>
@@ -11,7 +11,7 @@
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- include("alert", { text: "Client registered.", tone: "pos" }) %>
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
<% } -%>
<% if (locals.secret) { -%>
<section class="form-card" aria-labelledby="secret-h">
@@ -8,17 +8,17 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("field", form.nameField) %>
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="redirectUris">Redirect URIs</label>
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
</div>
<%- include("field", form.scopeField) %>
<%- include("partials/field", form.scopeField) %>
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
<span class="field-hint">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
@@ -14,7 +14,7 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Members</h2>
@@ -8,11 +8,11 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("field", form.nameField) %>
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member">First member</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
@@ -0,0 +1,2 @@
<%# One-line notice body (not-found / unavailable). Data: message. %>
<section class="notice"><p><%= message %></p></section>
@@ -16,7 +16,7 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2>
@@ -8,11 +8,11 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("field", form.nameField) %>
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member">Assign to</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
@@ -11,7 +11,7 @@
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("alert", { text: locals.error, tone: "neg" }) %>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
@@ -19,7 +19,7 @@
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("field", field) %>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
@@ -1,16 +1,16 @@
<%#
Role admin detail page: the role-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
Role admin create page: the role-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,8 +1,8 @@
<%#
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Role subject sets (src/admin/admin-roles.ts). Filter/sort/page round-trip the URL.
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
@@ -11,11 +11,11 @@
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,16 +1,16 @@
<%#
Users admin create/edit page: the user-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,8 +1,8 @@
<%#
Users admin list: the same building blocks as the dashboard, around the shell, but
backed by live Kratos identities (src/admin/admin-users.ts). Filter/sort/page all round-trip the URL.
backed by live Kratos identities (admin-users.ts). Filter/sort/page all round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
@@ -11,11 +11,11 @@
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -10,7 +10,7 @@ What it demonstrates:
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the built-in admin screens do.)
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
-90
View File
@@ -1,90 +0,0 @@
// Direct units for the admin section's pure nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four admin screens, so pin
// the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end.
import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import {
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
} from "./admin-nav.ts";
import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "../auth/csrf.ts";
import { GuardError } from "../auth/guards.ts";
import { DEFAULT_MENU } from "../ui/menu-config.ts";
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
function reqCtx(opts: { body?: string; cookie?: string; method?: string; user?: User | null } = {}): RequestContext {
const req = new IncomingMessage(new Socket());
req.method = opts.method ?? "GET";
req.url = "/admin/users";
if (opts.cookie) req.headers.cookie = opts.cookie;
req.push(opts.body ?? null);
if (opts.body != null) req.push(null);
return buildContext(req, new ServerResponse(req), { user: opts.user ?? null });
}
const labels = (nodes: { label: string }[]): string[] => nodes.map((n) => n.label);
// ---- nav helpers ----
test("adminSection: gated Admin header over the four screens; current marks the item + opens the header", () => {
const plain = adminSection();
assert.equal(plain.id, "admin");
assert.equal(plain.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(plain.open, undefined);
assert.deepEqual(plain.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.deepEqual(labels(plain.children ?? []), ["Users", "Groups", "Roles", "OAuth2 clients"]);
assert.ok(plain.children?.every((c) => c.current === undefined)); // nothing active
const onRoles = adminSection("roles");
assert.equal(onRoles.open, true);
assert.equal(onRoles.children?.find((c) => c.id === "roles")?.current, true);
assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined);
});
// (The in-screen admin sidebar is gone in — every page renders the one global menu, built by
// buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.)
// ---- auth gates ----
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
assert.throws(() => requireAdmin(reqCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // anonymous bounce remembers the page
assert.throws(() => requireAdmin(reqCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requireAdmin(reqCtx({ user: admin })), admin);
});
test("guardedForm: valid double-submit → the parsed body, bad/missing token → 403, non-POST → undefined", async () => {
const secret = "test-secret";
const token = issueCsrfToken(secret);
const post = (over: { body?: string; cookie?: string }) => reqCtx({ method: "POST", ...over });
// cookie token === submitted field, both a genuine signature → the form is returned
const ok = await guardedForm(post({ body: `${CSRF_FIELD}=${encodeURIComponent(token)}&name=Bo`, cookie: `${CSRF_COOKIE}=${token}` }), secret);
assert.equal(ok?.get("name"), "Bo");
await assert.rejects(guardedForm(post({ body: `${CSRF_FIELD}=${encodeURIComponent(token)}` }), secret), // no cookie
(e: unknown) => e instanceof GuardError && e.status === 403);
await assert.rejects(guardedForm(post({ body: `${CSRF_FIELD}=nope`, cookie: `${CSRF_COOKIE}=${token}` }), secret), // field ≠ cookie
(e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(await guardedForm(reqCtx({ method: "GET" }), secret), undefined); // not a mutation → no gate, no body read
});
// ---- confirm-page model ----
test("buildConfirmModel wires the danger action, message, passed-in nav and shell", () => {
const nav = [{ label: "Dashboard" }, { label: "Admin" }];
const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
csrfToken: "tok", menu: DEFAULT_MENU, message: "Delete ada@x.io?", nav, title: "Delete user", user: admin,
});
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
assert.equal(model.nav, nav); // the host's one global menu, passed through verbatim
assert.equal(model.shell.title, "Delete user");
});
-89
View File
@@ -1,89 +0,0 @@
// The built-in admin section of the menu. `adminSection()` is the one definition of the
// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
// global menu (`buildPluginChrome`) — composeNav drops the whole header + subtree for a
// non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no
// separate admin sidebar to drift.
import { readFormBody } from "../http/body.ts";
import type { RequestContext, User } from "../http/context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "../auth/csrf.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { buildShellContext } from "../ui/shell-context.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_ROLES_BASE = "/admin/roles";
export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The "Dashboard" link to the gated app home (/dashboard), composed into the global menu by
// buildPluginChrome (chrome.ts). It targets a gated route, so the chrome hides it from anonymous
// visitors (a non-signed-in click only dead-ends at /login).
export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
];
// The gated "Admin" header + its three screens; `current` marks the active screen and opens the
// header. The permission lives on the header, so composeNav drops the whole section for a non-admin.
export function adminSection(current?: AdminScreen): NavNode {
return {
children: ITEMS.map((it) => ({ ...it, ...(it.id === current ? { current: true } : {}) })),
icon: "i-shield",
id: "admin",
label: "Admin",
permission: ADMIN_PERMISSION,
...(current ? { open: true } : {}),
};
}
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps
// (anonymous → /login, non-admin → 403). Returns the (non-null) user for the handler to thread on.
export function requireAdmin(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
if (!ctx.roles.includes(ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return ctx.user;
}
// Read + CSRF-verify a mutation's form body once. Every admin write is a first-party POST form, so a
// POST without a valid double-submit token is refused (GuardError → 403); non-POST ⇒ undefined.
export async function guardedForm(ctx: RequestContext, csrfSecret: string): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!verifyCsrfRequest({ cookieHeader: ctx.req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
throw new GuardError(403, "invalid CSRF token");
}
return form;
}
// Build the model for the shared destructive-action confirm page (views/admin/confirm.ejs): a single
// danger action behind a deliberate second step, plus a cancel link. Reused by all admin screens.
// `nav` is the unified global menu (ctx.chrome.nav), passed in by the handler.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
csrfToken: string;
menu: MenuConfig;
message: string;
nav: NavNode[];
title: string;
user: User | null;
}) {
return {
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
nav: opts.nav,
shell: buildShellContext({ breadcrumbs: opts.breadcrumbs, csrfToken: opts.csrfToken, menu: opts.menu, title: opts.title, user: opts.user }),
};
}
+22 -5
View File
@@ -21,8 +21,14 @@ import { KratosError, type Flow, type FlowType, type KratosPublic, type Session,
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
// operator would after copying it into plugins/.
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
@@ -532,10 +538,11 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
// The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
// anonymous is bounced to sign in before any page renders (gate on /dashboard).
const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
assert.match(await admin.text(), /href="\/admin\/users"/);
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
// composition + role-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
assert.equal(dash.status, 200);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
assert.equal(anonDash.status, 303);
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
@@ -852,7 +859,7 @@ const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockK
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), ...opts });
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
@@ -1093,6 +1100,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/);
@@ -1111,6 +1123,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
assert.equal(store.length, before);
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
// collection: a delete with no token is refused (403) and removes nothing.
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
assert.ok(store.some((x) => x.id === store[1]!.id));
// Edit: email is read-only + prefilled; a post rewrites the name.
const target = store[0]!;
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
+14 -57
View File
@@ -3,11 +3,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { type AdminClientsDeps, handleAdminClients } from "../admin/admin-clients.ts";
import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.ts";
import { readFormBody } from "./body.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type User } from "./context.ts";
@@ -30,6 +25,7 @@ import { resolveLoginChallenge } from "../auth/oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts";
@@ -80,6 +76,12 @@ export function createApp(options: AppOptions = {}): Server {
const keto = options.keto;
const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin;
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
: undefined;
// Silent default so unit/integration tests stay quiet; server.ts injects the configured logger.
const log = options.log ?? createLogger({ level: "none" });
const menu = options.menu ?? DEFAULT_MENU;
@@ -99,8 +101,8 @@ export function createApp(options: AppOptions = {}): Server {
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
// `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared
// partials/ by the same root-relative name top-level views use (EJS tries relative first).
// `views: [viewsDir]` lets a view in a subfolder (e.g. partials/…) include() the shared partials/
// by the same root-relative name top-level views use (EJS tries relative first).
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
@@ -108,15 +110,6 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Built-in admin screens — wired only when their Ory clients are present (the writes go
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
// OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
@@ -190,8 +183,8 @@ export function createApp(options: AppOptions = {}): Server {
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest + the built-in admin screens.
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
// base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
@@ -210,7 +203,7 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf });
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.roles)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the role gets the 403 page.
@@ -226,42 +219,6 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
const result = await handleAdminUsers(ctx, csrf.token, adminDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
if (adminGroupsDeps && pathname.startsWith(ADMIN_GROUPS_BASE)) {
const result = await handleAdminGroups(ctx, csrf.token, adminGroupsDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
if (adminRolesDeps && pathname.startsWith(ADMIN_ROLES_BASE)) {
const result = await handleAdminRoles(ctx, csrf.token, adminRolesDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) {
const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
const flowType = AUTH_FLOWS[pathname];
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
@@ -481,7 +438,7 @@ export function createApp(options: AppOptions = {}): Server {
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
if (homePlugin) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
const result = (await homePlugin.home(homeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
@@ -503,7 +460,7 @@ export function createApp(options: AppOptions = {}): Server {
// A plugin may fully own the dashboard: render its handler against its own views, native
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (dashboardPlugin) {
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
+8
View File
@@ -46,6 +46,14 @@ test("buildContext defaults a missing request URL to /", () => {
assert.equal(buildContext(req, res).url.pathname, "/");
});
test("buildContext exposes ctx.system only when the host supplies it (else undefined)", () => {
const { req, res } = reqRes("/admin/users");
assert.equal(buildContext(req, res).system, undefined); // absent by default — a plugin must degrade
const revoke = (): void => {};
const system = { revoke };
assert.equal(buildContext(req, res, { system }).system, system); // threaded through unchanged
});
test("buildContext provides a logger: a silent default, or the host's request logger", () => {
const { req, res } = reqRes("/");
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
+6
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
@@ -27,6 +28,9 @@ export interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
url: URL;
user: User | null;
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
@@ -41,6 +45,7 @@ export interface BuildContextOptions {
chrome?: () => PageChrome;
log?: Log;
params?: Record<string, string>;
system?: SystemCapabilities;
user?: User | null;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
}
@@ -68,6 +73,7 @@ export function buildContext(
req,
res,
roles: user?.roles ?? [],
...(options.system ? { system: options.system } : {}),
url,
user,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
+7 -1
View File
@@ -40,7 +40,6 @@ test("discovers each folder's manifest, sorted, id derived from the folder name"
const badCases: Array<{ name: string; files: Record<string, string>; match: RegExp }> = [
{ name: "invalid folder name", files: { "Bad_Name/plugin.ts": full("x") }, match: /Bad_Name/ },
{ name: "reserved id shadows a host route", files: { "login/plugin.ts": full("login") }, match: /login.*reserved/s },
{ name: "reserved admin id shadows the admin screens", files: { "admin/plugin.ts": full("admin") }, match: /admin.*reserved/s },
{ name: "reserved oauth2 id shadows the provider routes", files: { "oauth2/plugin.ts": full("oauth2") }, match: /oauth2.*reserved/s },
{ name: "missing plugin.ts", files: { "broken/readme.txt": "x" }, match: /broken.*plugin\.ts/s },
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
@@ -71,6 +70,13 @@ test("a route + nav node may be marked public and load fine", async (t) => {
assert.equal(plugins[0]?.nav?.[0]?.public, true);
});
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
const dir = scaffold(t, { "admin/plugin.ts": full("admin") });
const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1);
assert.equal(plugins[0]?.id, "admin");
});
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir });
+12
View File
@@ -11,8 +11,20 @@ export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
export { parseListQuery } from "../ui/list-query.ts";
export { paginate } from "../ui/paginate.ts";
export type { PageModel } from "../ui/paginate.ts";
export { readFormBody } from "../http/body.ts";
export { CSRF_FIELD } from "../auth/csrf.ts";
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
// instant-revoke hook. Undefined unless the host wired them; the built-in admin plugin is the
// reference consumer. The Ory client types + their error classes are re-exported so a system
// plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
export type { SystemCapabilities } from "./system.ts";
export type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts";
export { KratosError } from "../auth/kratos-public.ts";
export { HydraError } from "../auth/hydra-admin.ts";
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
export { safeUrl } from "../http/safe-url.ts";
+5 -5
View File
@@ -11,7 +11,6 @@ import {
type Plugin,
type PluginManifest,
} from "./plugin.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { AUTH_FLOWS } from "../auth/flow-view.ts";
// A representative manifest exercising every field — its existence type-checks the contract.
@@ -115,12 +114,11 @@ test("findConflicts: each single slot (`home`/`dashboard`) may have one owner
// Drift guard: RESERVED_PLUGIN_IDS is a hand-maintained mirror of the host's own top-level mounts —
// a folder claiming one would silently shadow a built-in route. Derive the segments from the real
// route constants so adding a new auth flow or admin screen without reserving its id fails here.
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) is NOT reserved", () => {
// route constants so adding a new auth flow or provider route without reserving its id fails here.
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
const builtins = new Set<string>([
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
seg(ADMIN_USERS_BASE), seg(ADMIN_GROUPS_BASE), seg(ADMIN_ROLES_BASE), seg(ADMIN_CLIENTS_BASE), // → admin
"auth", // /auth/complete (login completion)
"logout", // POST /logout
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
@@ -129,6 +127,8 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
]);
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
// deliberately not reserved — a plugin folder named "home" is legal.
// deliberately not reserved — a plugin folder named "home" is legal. `admin` is likewise free: the
// admin screens ship as a drop-in plugin mounted at /admin, not a built-in route.
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
});
+6 -5
View File
@@ -89,12 +89,13 @@ export function isValidPluginId(id: string): boolean {
}
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
// /auth/complete, /logout, the /admin screens, the /oauth2 provider routes, the /public/ static).
// Plugin routes resolve before these, so a folder named one of them would silently shadow a
// built-in route — discovery refuses it, loud like any conflict. ("/" is owned by the `home` field,
// not a route, so it can't be shadowed and needs no reservation.)
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
// before these, so a folder named one of them would silently shadow a built-in route — discovery
// refuses it, loud like any conflict. ("/" is owned by the `home` field, not a route, so it can't be
// shadowed and needs no reservation.) Note `admin` is NOT reserved: the admin screens ship as a
// drop-in plugin (examples/plugins/admin, mounted at /admin), not a built-in route.
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
"admin", "auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
]);
export interface Semver {
+23
View File
@@ -0,0 +1,23 @@
// System capabilities: privileged host services a first-party/system plugin (the built-in admin
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api.
//
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
// treat each as optional and degrade when absent — the host does not fail a request over it.
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
// rather than pile them in flat.
export interface SystemCapabilities {
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
kratosAdmin?: KratosAdmin; // identity admin (Kratos); present when the Kratos admin client is wired
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens; present when the denylist is on
}
+17 -5
View File
@@ -15,6 +15,18 @@ const scheduling: Plugin = {
};
// A plugin with a public nav node (reachable by anyone, signed in or not).
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
const adminLike: Plugin = {
apiVersion: "1.0.0", id: "admin",
nav: [{
children: [
{ href: "/admin/users", id: "users", label: "Users" },
{ href: "/admin/groups", id: "groups", label: "Groups" },
],
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
}],
};
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
@@ -23,8 +35,8 @@ test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden
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
// at /login, so it's dropped. Scheduling's only child is gated (dropped), admin gated (dropped);
// the explicitly public Portal node remains.
// at /login, so it's dropped. Scheduling's only child is gated (dropped); the explicitly public
// Portal node remains.
assert.deepEqual(labels(chrome.nav), ["Portal"]);
});
@@ -45,12 +57,12 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
assert.equal(chrome.user.name, "ada"); // email local part
});
test("an admin sees the gated admin section; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
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", roles: ["admin"] } });
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
// /admin/users/new is under the Users base (/admin/users) → that leaf is current, not Groups/Roles.
// /admin/users/new is under the Users base (/admin/users) → that leaf is current, not Groups.
assert.equal(admin.children!.find((c) => c.label === "Users")!.current, true);
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
});
+10 -6
View File
@@ -1,16 +1,20 @@
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
// admin screens render. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run
// through composeNav (override + per-user filter) and current-marked for the request path.
// every plugin renders. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
// nav is the global menu — Dashboard + every plugin's fragment (admin screens included, when the
// admin plugin is installed) — run through composeNav (override + per-user filter) and
// current-marked for the request path.
import { adminSection, DASHBOARD_NAV } from "../admin/admin-nav.ts";
import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { shellUser, type ShellUser } from "./shell-context.ts";
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
// only to a signed-in user (an anonymous click would only dead-end at /login).
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
export interface PageChrome {
brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
@@ -30,10 +34,10 @@ export interface ChromeOptions {
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell) it would only dead-end at /login.
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
fragments.push([adminSection()]);
const roles = opts.user?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, roles);
+1 -1
View File
@@ -4,7 +4,7 @@ For each todo item, interview the user extensively to deeply understand the scop
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
- [x] The config folder should be empty and the current settings in the menu.ts should be the fallback default. IF a menu.ts where to appear in that folder, it should override the default settings with whatever is in it. The idea is the folder should be empty by default and you mount it in your docker container with your config.
- [ ] Make the internal admin pages for users groups etc into a plugin instead in the examples folder and remove them from the internal source. Add a part in the quick start about copying this plugin into the plugins folder to enable GUI user- and group admining.
- [x] Make the internal admin pages for users groups etc into a plugin instead in the examples folder and remove them from the internal source. Add a part in the quick start about copying this plugin into the plugins folder to enable GUI user- and group admining.
- [ ] Build and publish docker image as CI/CD.
- [ ] Add i18n support.
- [ ] Set up CI/CD. Tests on push to any branch. Require PR to main and don't allow merge if tests does not pass. Publish docker image on valid semver tag. Sync up to github when merging to main.
-17
View File
@@ -1,17 +0,0 @@
<%#
OAuth2 client detail page: the client-detail body (info · one-time secret · delete) in the
shell. Doubles as the post-register page when `created`/`secret` are set.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: model.shell.csrfToken, del: model.delete, secret: model.secret });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>
-16
View File
@@ -1,16 +0,0 @@
<%#
OAuth2 client register page: the client-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const body = include("partials/client-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>
-17
View File
@@ -1,17 +0,0 @@
<%#
Admin destructive-action confirmation page: the confirm body in the app shell. Model
from buildConfirmModel: { message, confirm:{action,label}, cancelHref, nav, shell }.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const body = include("partials/confirm-body", { cancelHref: model.cancelHref, confirm: model.confirm, csrfToken: model.shell.csrfToken, message: model.message });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>
-16
View File
@@ -1,16 +0,0 @@
<%#
Group admin create page: the group-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const body = include("partials/group-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>
-16
View File
@@ -1,16 +0,0 @@
<%#
Role admin create page: the role-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: model.nav });
const body = include("partials/role-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken,
nav,
theme: model.shell.theme,
title: model.shell.title,
user: model.shell.user,
}) %>