Rename the admin example plugin to auth-admin, and let config/menu.ts hide or rename a profile-menu entry
CI / full-gate (push) Successful in 3m32s
CI / full-gate (push) Successful in 3m32s
This commit is contained in:
@@ -218,7 +218,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
||||||
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
|
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
|
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
|
||||||
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
into a small `withX` wrapper — see `examples/plugins/auth-admin/`.
|
||||||
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
||||||
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
||||||
`grandfathered` list or raise a ceiling there.
|
`grandfathered` list or raise a ceiling there.
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ The host's contract version is now 0.5.0, so discovery refuses a plugin whose `a
|
|||||||
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
|
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
|
||||||
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
|
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
|
||||||
|
|
||||||
|
### An operator can hide or rename a plugin's profile-menu entry
|
||||||
|
|
||||||
|
A `profileMenu` entry takes an optional `id`, and `config/menu.ts`'s `override.hide` and
|
||||||
|
`override.rename` apply to it as they do to a nav node. Nav nodes and profile-menu entries share one
|
||||||
|
set of ids, so the boot refuses an entry whose `id` another entry or nav node already uses.
|
||||||
|
|
||||||
|
### The admin example plugin is now `auth-admin`, and its screens live at `/auth-admin/*`
|
||||||
|
|
||||||
|
The Users, Groups, OAuth2-clients and Plugin-settings screens move from `/admin/*` to
|
||||||
|
`/auth-admin/*`. An installed `plugins/admin/` keeps working until you replace it: delete it, copy
|
||||||
|
`examples/plugins/auth-admin/` into `plugins/`, and move any `locales/plugins/admin/` to
|
||||||
|
`locales/plugins/auth-admin/`. The boot refuses the two installed together, since both declare the
|
||||||
|
nav section `admin`.
|
||||||
|
|
||||||
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
|
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
|
||||||
|
|
||||||
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
|
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
|||||||
**`admin@plainpages.local` / `admin`**.
|
**`admin@plainpages.local` / `admin`**.
|
||||||
|
|
||||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||||
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/auth-admin/*`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
|
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
|
||||||
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
|
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
|
||||||
one-shot `bootstrap` service, and only `up` re-runs it. See
|
one-shot `bootstrap` service, and only `up` re-runs it. See
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/).
|
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/).
|
||||||
|
|
||||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
**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`:
|
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
|
||||||
@@ -200,11 +200,11 @@ public front with an authenticated area behind it.
|
|||||||
|
|
||||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
- **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.
|
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||||
- **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
|
- **Opt-in auth-admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through
|
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/) — an ordinary plugin, reaching Ory through
|
||||||
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
|
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
|
||||||
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
||||||
blocks the admin plugin uses.
|
blocks the auth-admin plugin uses.
|
||||||
|
|
||||||
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
||||||
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
||||||
@@ -321,9 +321,9 @@ Against the reference plugins' actual routes:
|
|||||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
| `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
| `GET /auth-admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
||||||
| `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
| `POST /auth-admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
||||||
| `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
| `GET /auth-admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
||||||
|
|
||||||
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
|
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
|
||||||
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
|
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
|
||||||
@@ -333,7 +333,7 @@ door they cannot open.
|
|||||||
|
|
||||||
### Granting a permission
|
### Granting a permission
|
||||||
|
|
||||||
Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
Write the tuple. The auth-admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
||||||
the declared permissions as a checkbox list — or use Keto's write API directly:
|
the declared permissions as a checkbox list — or use Keto's write API directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -418,7 +418,7 @@ URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also na
|
|||||||
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
|
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
|
||||||
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2`
|
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2`
|
||||||
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
|
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
|
||||||
silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin.
|
silently shadow a built-in. `auth-admin` is **not** reserved: the admin screens are themselves a plugin.
|
||||||
|
|
||||||
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
|
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
|||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||||
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. |
|
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, id?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon`, `id` and `label` follow the same rules as a `nav` node. |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
||||||
@@ -651,7 +651,7 @@ interface SystemCapabilities { // every field optional — present only
|
|||||||
|
|
||||||
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
|
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
|
||||||
every field as optional and **degrades when absent** — the host never fails a request over it. The
|
every field as optional and **degrades when absent** — the host never fails a request over it. The
|
||||||
**admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer:
|
**auth-admin plugin** ([`examples/plugins/auth-admin/`](examples/plugins/auth-admin/)) is the reference consumer:
|
||||||
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
|
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
|
||||||
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
|
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
|
||||||
a missing capability renders a themed 503.
|
a missing capability renders a themed 503.
|
||||||
@@ -731,7 +731,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
||||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
| `nav-id` | error | A nav node or profile-menu entry `id` is used more than once — the central override targets ids, so they must be unique across both. |
|
||||||
| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). |
|
| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). |
|
||||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
|
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
|
||||||
@@ -891,7 +891,7 @@ installed plugin declares is only *reported* — it is usually a typo in the one
|
|||||||
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
||||||
`onBoot` warns too: they resolve, but nothing receives them.
|
`onBoot` warns too: they resolve, but nothing receives them.
|
||||||
|
|
||||||
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
|
**Reading what a deployment is configured with.** The auth-admin plugin's **Plugin settings** screen
|
||||||
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
||||||
each, and whether the value came from the environment or the declared default.
|
each, and whether the value came from the environment or the declared default.
|
||||||
|
|
||||||
@@ -1003,8 +1003,8 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
|
|
||||||
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
||||||
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
|
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
|
||||||
boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and
|
boot) — where the operator reorders, renames, groups, or hides items (by node `id`), hides or
|
||||||
sets branding (app name, logo, default theme). The override always wins, applied before
|
renames a profile-menu entry (by its `id`), and sets branding (app name, logo, default theme). The override always wins, applied before
|
||||||
the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
||||||
|
|
||||||
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
|
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
|
||||||
@@ -1048,7 +1048,7 @@ fully styled and zero-JS:
|
|||||||
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
||||||
zero I/O), `check(relation, object)` (the one live Keto call).
|
zero I/O), `check(relation, object)` (the one live Keto call).
|
||||||
|
|
||||||
A list page's pagination, from URL to partial (`examples/plugins/admin/admin-users.ts` is the full
|
A list page's pagination, from URL to partial (`examples/plugins/auth-admin/admin-users.ts` is the full
|
||||||
page):
|
page):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -1137,7 +1137,7 @@ catalog for it. There is **no locale cookie**: the URL is the only place a choic
|
|||||||
link is shareable and a page is what its address says it is. When the URL asked for a language, the
|
link is shareable and a page is what its address says it is. When the URL asked for a language, the
|
||||||
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
|
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
|
||||||
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
|
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
|
||||||
current URL may answer no GET (`POST /admin/users/:id/recovery` has no GET sibling), so the host
|
current URL may answer no GET (`POST /auth-admin/users/:id/recovery` has no GET sibling), so the host
|
||||||
points the picker at this path when it answers GET, else the page the form was submitted from, else
|
points the picker at this path when it answers GET, else the page the form was submitted from, else
|
||||||
`/` — switching language there leaves that POST's own result behind.
|
`/` — switching language there leaves that POST's own result behind.
|
||||||
|
|
||||||
@@ -1425,7 +1425,7 @@ Nothing in the menu or first-party pages needs Hydra.
|
|||||||
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
|
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
|
||||||
JWT cookie.
|
JWT cookie.
|
||||||
|
|
||||||
Clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`): Hydra
|
Clients are registered from the auth-admin plugin's **OAuth2 clients** screen (`/auth-admin/clients`): Hydra
|
||||||
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
|
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
|
||||||
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
|
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
|
||||||
|
|
||||||
@@ -1635,7 +1635,7 @@ tightened a manifest rule since. Discovery fails loud at boot rather than runnin
|
|||||||
honour, naming the plugin and the rule:
|
honour, naming the plugin and the rule:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
|
rm -rf plugins/auth-admin && cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1668,7 +1668,7 @@ OpenTelemetry's HTTP conventions:
|
|||||||
- `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
|
- `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
|
||||||
`http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
|
`http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
|
||||||
`client.address` and `requestId`.
|
`client.address` and `requestId`.
|
||||||
- `http.route`: the matched pattern, such as `/admin/users/:id`. It is absent when no route
|
- `http.route`: the matched pattern, such as `/auth-admin/users/:id`. It is absent when no route
|
||||||
matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
|
matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
|
||||||
- Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
|
- Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
|
||||||
attribute as a string.
|
attribute as a string.
|
||||||
@@ -1799,7 +1799,7 @@ locales/ Drop-in mount: extra or replacement catalogs. Ships empty.
|
|||||||
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
|
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
|
||||||
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
|
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
|
||||||
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
|
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
|
||||||
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/admin/,
|
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/auth-admin/,
|
||||||
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
||||||
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
||||||
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
||||||
|
|||||||
+3
-3
@@ -11,7 +11,7 @@ reader can name.
|
|||||||
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
|
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
|
||||||
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
|
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
|
||||||
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
|
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
|
||||||
is a drop-in plugin (`examples/plugins/admin/`).
|
is a drop-in plugin (`examples/plugins/auth-admin/`).
|
||||||
|
|
||||||
## Plugins and config import the host only through the two barrels
|
## Plugins and config import the host only through the two barrels
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ read-only parent is EROFS, and the container never starts.
|
|||||||
|
|
||||||
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
|
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
|
||||||
|
|
||||||
Inside the admin example it keeps the route table and the in-handler guard deriving from one
|
Inside the auth-admin example it keeps the route table and the in-handler guard deriving from one
|
||||||
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
|
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
|
||||||
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ keys.
|
|||||||
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
|
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
|
||||||
that softens, hiding it after a POST is simpler.
|
that softens, hiding it after a POST is simpler.
|
||||||
|
|
||||||
A POST-rendered URL often answers no GET (`POST /admin/users/:id/recovery`), so the host resolves
|
A POST-rendered URL often answers no GET (`POST /auth-admin/users/:id/recovery`), so the host resolves
|
||||||
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
|
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
|
||||||
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
|
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
|
||||||
|
|
||||||
|
|||||||
@@ -440,3 +440,20 @@ Read most (opens and searches):
|
|||||||
- `src/ci-gate.test.ts` 1
|
- `src/ci-gate.test.ts` 1
|
||||||
- `src/function-limits.test.ts` 1
|
- `src/function-limits.test.ts` 1
|
||||||
- `src/plugin-host/discovery.ts` 1
|
- `src/plugin-host/discovery.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 7100f96a1d8d, finished 2026-11-08T20:19:54Z
|
||||||
|
|
||||||
|
96357 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `AGENTS.md` 11
|
||||||
|
- `README.md` 4
|
||||||
|
- `docs/decisions.md` 4
|
||||||
|
- `.gitea/workflows/release.yml` 2
|
||||||
|
- `examples/plugins/admin/admin-shared.ts` 2
|
||||||
|
- `src/postgres.test.ts` 2
|
||||||
|
- `todo.md` 2
|
||||||
|
- `src/auth/gate.ts` 1
|
||||||
|
- `src/auth/jwt.ts` 1
|
||||||
|
- `src/server.ts` 1
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
# The base's full depends_on applies (Hydra included — the auth-admin plugin's OAuth2-clients
|
||||||
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||||
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||||
# Kratos offers, mapped through the committed claims jsonnet.
|
# Kratos offers, mapped through the committed claims jsonnet.
|
||||||
@@ -24,18 +24,18 @@ services:
|
|||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 30
|
retries: 30
|
||||||
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
|
# 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
|
# gated /scheduling/shifts page and the /auth-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).
|
# plugin, mounted at /app/plugins/auth-admin, reaching the host's Ory clients via ctx.system).
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
|
||||||
|
|
||||||
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
|
||||||
|
|
||||||
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
|
# 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
|
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
|
||||||
|
|||||||
+26
-26
@@ -55,7 +55,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
||||||
// chosen language is most easily dropped; the core building blocks carry it through.
|
// chosen language is most easily dropped; the core building blocks carry it through.
|
||||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||||
await page.goto("/admin/users?locale=sv-SE");
|
await page.goto("/auth-admin/users?locale=sv-SE");
|
||||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
||||||
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
||||||
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
||||||
await page.goto("/admin/users?locale=sv-SE");
|
await page.goto("/auth-admin/users?locale=sv-SE");
|
||||||
await page.getByRole("link", { name: "Ny användare" }).click();
|
await page.getByRole("link", { name: "Ny användare" }).click();
|
||||||
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
||||||
await page.getByRole("button", { name: "Skapa användare" }).click();
|
await page.getByRole("button", { name: "Skapa användare" }).click();
|
||||||
@@ -89,7 +89,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
|
|
||||||
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
||||||
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(`${editHref}`);
|
await page.goto(`${editHref}`);
|
||||||
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
||||||
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||||
@@ -108,33 +108,33 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
||||||
// assert they're in the DOM, not necessarily visible).
|
// assert they're in the DOM, not necessarily visible).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
|
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(1);
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
||||||
const email = `e2e-${suffix}@plainpages.local`;
|
const email = `e2e-${suffix}@plainpages.local`;
|
||||||
await page.goto("/admin/users/new");
|
await page.goto("/auth-admin/users/new");
|
||||||
await page.fill('input[name="email"]', email);
|
await page.fill('input[name="email"]', email);
|
||||||
await page.fill('input[name="first"]', "E2E");
|
await page.fill('input[name="first"]', "E2E");
|
||||||
await page.fill('input[name="last"]', "User");
|
await page.fill('input[name="last"]', "User");
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
|
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); // PRG back to the list
|
||||||
const row = page.locator("tr", { hasText: email });
|
const row = page.locator("tr", { hasText: email });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
||||||
// scrolling table around the row cannot clip the panel.
|
// scrolling table around the row cannot clip the panel.
|
||||||
await row.locator("button.kebab").click();
|
await row.locator("button.kebab").click();
|
||||||
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
|
await expect(row.locator('a[href^="/auth-admin/users/"]').first()).toBeVisible();
|
||||||
|
|
||||||
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
||||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(`${editHref}/delete`);
|
await page.goto(`${editHref}/delete`);
|
||||||
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
|
||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,8 +143,8 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const email = `admin-recovery-${suffix}@plainpages.local`;
|
const email = `admin-recovery-${suffix}@plainpages.local`;
|
||||||
await createIdentity(email);
|
await createIdentity(email);
|
||||||
await page.goto("/admin/users");
|
await page.goto("/auth-admin/users");
|
||||||
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(editHref!);
|
await page.goto(editHref!);
|
||||||
await page.getByRole("button", { name: "Generate recovery code" }).click();
|
await page.getByRole("button", { name: "Generate recovery code" }).click();
|
||||||
const code = await page.locator(".recovery-code code").innerText();
|
const code = await page.locator(".recovery-code code").innerText();
|
||||||
@@ -162,27 +162,27 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||||
// enforces it); pick the first option (a user) from the required picker.
|
// enforces it); pick the first option (a user) from the required picker.
|
||||||
const group = `e2e-grp-${suffix}`;
|
const group = `e2e-grp-${suffix}`;
|
||||||
await page.goto("/admin/groups/new");
|
await page.goto("/auth-admin/groups/new");
|
||||||
await page.fill('input[name="name"]', group);
|
await page.fill('input[name="name"]', group);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/groups(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(group);
|
await expect(page.locator("main")).toContainText(group);
|
||||||
|
|
||||||
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||||
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||||
await page.goto(`/admin/groups/${group}`);
|
await page.goto(`/auth-admin/groups/${group}`);
|
||||||
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
|
allowConsole(/status of 409 .*\/auth-admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
|
||||||
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
|
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
|
||||||
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
|
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
|
||||||
await staleTab.goto(`/admin/groups/${group}`);
|
await staleTab.goto(`/auth-admin/groups/${group}`);
|
||||||
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||||
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||||
await expect(scheduling).not.toBeChecked();
|
await expect(scheduling).not.toBeChecked();
|
||||||
await scheduling.check();
|
await scheduling.check();
|
||||||
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
await expect(page).toHaveURL(new RegExp(`/auth-admin/groups/${group}`));
|
||||||
await expect(page.getByText("Permissions saved.")).toBeVisible();
|
await expect(page.getByText("Permissions saved.")).toBeVisible();
|
||||||
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
|
|
||||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||||
const name = `e2e-client-${suffix}`;
|
const name = `e2e-client-${suffix}`;
|
||||||
await page.goto("/admin/clients");
|
await page.goto("/auth-admin/clients");
|
||||||
await page.getByRole("link", { name: "Register client" }).click();
|
await page.getByRole("link", { name: "Register client" }).click();
|
||||||
await page.fill('input[name="name"]', name);
|
await page.fill('input[name="name"]', name);
|
||||||
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||||
@@ -210,17 +210,17 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||||
|
|
||||||
// Listed; the row header links to the plain detail, which never shows the secret again.
|
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||||
await page.goto("/admin/clients");
|
await page.goto("/auth-admin/clients");
|
||||||
const row = page.locator("tr", { hasText: name });
|
const row = page.locator("tr", { hasText: name });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await row.getByRole("link", { name }).click();
|
await row.getByRole("link", { name }).click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
await expect(page).toHaveURL(new RegExp(`/auth-admin/clients/${clientId}`));
|
||||||
await expect(page.locator("#csecret")).toHaveCount(0);
|
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||||
|
|
||||||
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||||
await page.getByRole("link", { name: "Delete client" }).click();
|
await page.getByRole("link", { name: "Delete client" }).click();
|
||||||
await page.getByRole("button", { name: "Delete client" }).click();
|
await page.getByRole("button", { name: "Delete client" }).click();
|
||||||
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/clients(\?|$)/);
|
||||||
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||||
await page.goto("/admin/plugin-settings");
|
await page.goto("/auth-admin/plugin-settings");
|
||||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||||
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
||||||
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
||||||
@@ -257,7 +257,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,13 +265,13 @@ test("return_to: a deep link while logged out returns to that page after login",
|
|||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||||
await page.goto("/admin/users");
|
await page.goto("/auth-admin/users");
|
||||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||||
await page.locator('.auth-form button[type="submit"]').click();
|
await page.locator('.auth-form button[type="submit"]').click();
|
||||||
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
||||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
|
||||||
await expect(page.locator("h1")).toHaveText("Users");
|
await expect(page.locator("h1")).toHaveText("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||||
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
|
|||||||
| Path | Copy into | Example of |
|
| 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 `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
| [`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 `@plainpages/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 / Permissions / 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). |
|
| [`plugins/auth-admin/`](plugins/auth-admin/) | `plugins/auth-admin/` | The system-admin plugin: the Users / Groups / Permissions / 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/auth-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). |
|
| [`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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// cp examples/config/menu.ts config/menu.ts
|
// cp examples/config/menu.ts config/menu.ts
|
||||||
// Absent config = built-in defaults.
|
// Absent config = built-in defaults.
|
||||||
//
|
//
|
||||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
// Brand the app, reorder/rename/group/hide nav nodes and rename/hide profile-menu entries (by their
|
||||||
// override always wins, applied before the per-user permission filter. Every field is optional.
|
// `id`) across all plugins — the override always wins, applied before the per-user permission filter. Every field is optional.
|
||||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
||||||
|
|
||||||
import { defineMenu } from "#menu-config";
|
import { defineMenu } from "#menu-config";
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Admin — the system-administration plugin
|
# auth-admin — the plugin that administers users, groups, permissions and OAuth2 clients
|
||||||
|
|
||||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
||||||
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
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:
|
(it keeps the id and mount path `auth-admin`, so the screens live at `/auth-admin/*`) and restart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
+5
-5
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
|
|||||||
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
||||||
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
||||||
|
|
||||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "OAuth2 clients");
|
assert.equal(all.title, "OAuth2 clients");
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
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.text, "app-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00");
|
||||||
|
|
||||||
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
|
const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,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", () => {
|
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||||
assert.equal(m.title, "Register client");
|
assert.equal(m.title, "Register client");
|
||||||
assert.equal(m.form.action, "/admin/clients");
|
assert.equal(m.form.action, "/auth-admin/clients");
|
||||||
assert.equal(m.form.submitLabel, "Register client");
|
assert.equal(m.form.submitLabel, "Register client");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
assert.equal(m.form.nameField.required, true);
|
assert.equal(m.form.nameField.required, true);
|
||||||
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
|
|||||||
|
|
||||||
const plain = buildClientDetailModel({ client });
|
const plain = buildClientDetailModel({ client });
|
||||||
assert.equal(plain.title, "Acme");
|
assert.equal(plain.title, "Acme");
|
||||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete");
|
||||||
assert.equal(plain.created, false);
|
assert.equal(plain.created, false);
|
||||||
assert.equal(plain.secret, undefined);
|
assert.equal(plain.secret, undefined);
|
||||||
|
|
||||||
+6
-6
@@ -257,13 +257,13 @@ const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionNam
|
|||||||
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
||||||
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
|
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
|
||||||
|
|
||||||
// GET /admin/clients — the list.
|
// GET /auth-admin/clients — the list.
|
||||||
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||||
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
|
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
// POST /auth-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.
|
// 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 }) => {
|
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||||
const input = readClientInput((await guardedForm(ctx))!);
|
const input = readClientInput((await guardedForm(ctx))!);
|
||||||
@@ -280,13 +280,13 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
|||||||
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/clients/new — the register form.
|
// GET /auth-admin/clients/new — the register form.
|
||||||
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
||||||
|
|
||||||
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
// GET /auth-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)));
|
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
||||||
|
|
||||||
// GET /admin/clients/:id/delete — the deliberate confirm step.
|
// GET /auth-admin/clients/:id/delete — the deliberate confirm step.
|
||||||
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||||
const base = detailHref(id);
|
const base = detailHref(id);
|
||||||
const name = toClientView(client).name;
|
const name = toClientView(client).name;
|
||||||
@@ -298,7 +298,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
|||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/clients/:id/delete — perform it.
|
// POST /auth-admin/clients/:id/delete — perform it.
|
||||||
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
await hydra.deleteClient(id);
|
await hydra.deleteClient(id);
|
||||||
+2
-2
@@ -36,8 +36,8 @@ test("grantDiff ignores anything the plugins don't declare, in both directions",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||||
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
|
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||||
assert.equal(picker.action, "/admin/users/u1/permissions");
|
assert.equal(picker.action, "/auth-admin/users/u1/permissions");
|
||||||
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
||||||
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
||||||
assert.equal(picker.choices[0]?.description, "View users");
|
assert.equal(picker.choices[0]?.description, "View users");
|
||||||
+8
-8
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
|
|||||||
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||||
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
||||||
|
|
||||||
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "Groups");
|
assert.equal(all.title, "Groups");
|
||||||
// The group name is the row header, linking to its detail page.
|
// 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 } };
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
assert.equal(first.rowHeader.text, "team-00");
|
assert.equal(first.rowHeader.text, "team-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/groups/team-00");
|
assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00");
|
||||||
|
|
||||||
// Search narrows + shows a pill.
|
// Search narrows + shows a pill.
|
||||||
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
|
const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
// Sort by members descending puts the biggest group first.
|
// Sort by members descending puts the biggest group first.
|
||||||
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
|
const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" });
|
||||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
|
|||||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
assert.equal(m.title, "New group");
|
assert.equal(m.title, "New group");
|
||||||
assert.equal(m.form.action, "/admin/groups");
|
assert.equal(m.form.action, "/auth-admin/groups");
|
||||||
assert.equal(m.form.submitLabel, "Create group");
|
assert.equal(m.form.submitLabel, "Create group");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
assert.equal(m.form.nameField.name, "name");
|
assert.equal(m.form.nameField.name, "name");
|
||||||
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
|
|||||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
||||||
assert.equal(m.title, "eng");
|
assert.equal(m.title, "eng");
|
||||||
assert.equal(m.members.rows.length, 2);
|
assert.equal(m.members.rows.length, 2);
|
||||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete");
|
||||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
assert.equal(m.add.action, "/auth-admin/groups/eng/members");
|
||||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
assert.equal(m.delete.action, "/auth-admin/groups/eng/delete");
|
||||||
});
|
});
|
||||||
+9
-9
@@ -331,13 +331,13 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
|
|||||||
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /admin/groups — the list.
|
// GET /auth-admin/groups — the list.
|
||||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
// POST /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||||
export const groupsCreate = withGroups(async (deps) => {
|
export const groupsCreate = withGroups(async (deps) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
@@ -353,7 +353,7 @@ export const groupsCreate = withGroups(async (deps) => {
|
|||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/groups/new — the create form.
|
// GET /auth-admin/groups/new — the create form.
|
||||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||||
|
|
||||||
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
||||||
@@ -379,11 +379,11 @@ async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: str
|
|||||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
|
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /admin/groups/:name — the detail + membership page.
|
// GET /auth-admin/groups/:name — the detail + membership page.
|
||||||
export const groupsDetail = withGroupName((deps, name) =>
|
export const groupsDetail = withGroupName((deps, name) =>
|
||||||
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
|
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
|
||||||
|
|
||||||
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
// POST /auth-admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||||
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
||||||
// the documented instant-revoke tradeoff for anything held through a group.
|
// the documented instant-revoke tradeoff for anything held through a group.
|
||||||
export const groupsPermissions = withGroupName(async (deps, name) => {
|
export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||||
@@ -406,7 +406,7 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
|
|||||||
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||||
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||||
@@ -414,7 +414,7 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
|||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
// GET /auth-admin/groups/:name/delete — the deliberate confirm step.
|
||||||
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||||
const lost = await lostOnDelete(deps, name);
|
const lost = await lostOnDelete(deps, name);
|
||||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||||
@@ -427,7 +427,7 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
|||||||
}) }, view: "confirm" };
|
}) }, view: "confirm" };
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
// POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||||
export const groupsDelete = withGroupName(async (deps, name) => {
|
export const groupsDelete = withGroupName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
@@ -444,7 +444,7 @@ export const groupsDelete = withGroupName(async (deps, name) => {
|
|||||||
return { redirect: ADMIN_GROUPS_BASE };
|
return { redirect: ADMIN_GROUPS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups/:name/members/delete — remove one member.
|
// POST /auth-admin/groups/:name/members/delete — remove one member.
|
||||||
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
+1
-1
@@ -68,7 +68,7 @@ function valueLabel(setting: SettingSummary, t: Translate): string {
|
|||||||
return setting.value ?? t("admin.pluginSettings.unset");
|
return setting.value ?? t("admin.pluginSettings.unset");
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /admin/plugin-settings
|
// GET /auth-admin/plugin-settings
|
||||||
export const pluginSettingsList: RouteHandler = (ctx) => {
|
export const pluginSettingsList: RouteHandler = (ctx) => {
|
||||||
requirePermission(ctx, "plugin-settings");
|
requirePermission(ctx, "plugin-settings");
|
||||||
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
||||||
+3
-3
@@ -15,7 +15,7 @@ const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:rea
|
|||||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
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 {
|
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 url = new URL("http://localhost/auth-admin/users");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
@@ -36,7 +36,7 @@ test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own re
|
|||||||
assert.equal(ADMIN_NAV.permission, undefined);
|
assert.equal(ADMIN_NAV.permission, undefined);
|
||||||
assert.equal(ADMIN_NAV.href, undefined);
|
assert.equal(ADMIN_NAV.href, undefined);
|
||||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
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/clients", "/admin/plugin-settings"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
||||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||||
@@ -64,7 +64,7 @@ test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
|
|||||||
// ---- auth gates ----
|
// ---- auth gates ----
|
||||||
|
|
||||||
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fauth-admin%2Fusers"); // bounce remembers the page
|
||||||
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||||
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
||||||
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
||||||
+4
-4
@@ -9,10 +9,10 @@ import enUS from "./i18n/en-US.ts";
|
|||||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||||
|
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/auth-admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/auth-admin/groups";
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/auth-admin/clients";
|
||||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings";
|
||||||
|
|
||||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||||
+10
-10
@@ -38,42 +38,42 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
|
|||||||
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
||||||
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
||||||
|
|
||||||
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "Users");
|
assert.equal(all.title, "Users");
|
||||||
|
|
||||||
// Search narrows to one and shows a pill.
|
// Search narrows to one and shows a pill.
|
||||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
// Status filter keeps only inactive identities.
|
// Status filter keeps only inactive identities.
|
||||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
|
const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" });
|
||||||
assert.equal(inactive.pagination.summary.total, 15);
|
assert.equal(inactive.pagination.summary.total, 15);
|
||||||
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
||||||
|
|
||||||
// Sort by email descending reverses the order.
|
// Sort by email descending reverses the order.
|
||||||
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
||||||
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
|
const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" });
|
||||||
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
||||||
|
|
||||||
// Edit link points at the per-identity route.
|
// Edit link points at the per-identity route.
|
||||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/auth-admin/users/${people[0]!.id}$`));
|
||||||
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
|
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
|
||||||
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
|
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
|
||||||
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
|
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
|
||||||
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
|
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!;
|
||||||
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
|
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-admin/users/u1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
||||||
assert.equal(m.title, "New user");
|
assert.equal(m.title, "New user");
|
||||||
assert.equal(m.form.action, "/admin/users");
|
assert.equal(m.form.action, "/auth-admin/users");
|
||||||
assert.equal(m.form.submitLabel, "Create user");
|
assert.equal(m.form.submitLabel, "Create user");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
const names = m.form.fields.map((f) => f.name);
|
const names = m.form.fields.map((f) => f.name);
|
||||||
@@ -85,7 +85,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", () => {
|
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||||
const m = buildUserFormModel({ identity: identity(3) });
|
const m = buildUserFormModel({ identity: identity(3) });
|
||||||
assert.equal(m.title, "Edit user");
|
assert.equal(m.title, "Edit user");
|
||||||
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
assert.equal(m.form.action, `/auth-admin/users/${id(3)}`);
|
||||||
assert.equal(m.form.submitLabel, "Save changes");
|
assert.equal(m.form.submitLabel, "Save changes");
|
||||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||||
assert.equal(email.value, "user3@example.com");
|
assert.equal(email.value, "user3@example.com");
|
||||||
@@ -109,7 +109,7 @@ test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a
|
|||||||
for (const [opts, title, crumb] of cases) {
|
for (const [opts, title, crumb] of cases) {
|
||||||
const m = buildUserFormModel(opts);
|
const m = buildUserFormModel(opts);
|
||||||
assert.equal(m.title, title);
|
assert.equal(m.title, title);
|
||||||
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
|
assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
+10
-10
@@ -288,13 +288,13 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) =>
|
|||||||
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
||||||
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
||||||
|
|
||||||
// GET /admin/users — the filtered/sorted/paged list.
|
// GET /auth-admin/users — the filtered/sorted/paged list.
|
||||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
// POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||||
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||||
const input = readUserInput((await guardedForm(ctx))!);
|
const input = readUserInput((await guardedForm(ctx))!);
|
||||||
try {
|
try {
|
||||||
@@ -307,10 +307,10 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
|||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/users/new — the empty create form.
|
// GET /auth-admin/users/new — the empty create form.
|
||||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
||||||
|
|
||||||
// GET /admin/users/:id — the edit form, prefilled.
|
// GET /auth-admin/users/:id — the edit form, prefilled.
|
||||||
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||||
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
|
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
|
||||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||||
@@ -342,7 +342,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
// POST /auth-admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||||
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
||||||
// revokes their live tokens so it lands now rather than at the next re-mint.
|
// revokes their live tokens so it lands now rather than at the next re-mint.
|
||||||
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||||
@@ -376,7 +376,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
|||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
// POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||||
const { ctx, kratosAdmin } = deps;
|
const { ctx, kratosAdmin } = deps;
|
||||||
const input = readUserInput((await guardedForm(ctx))!);
|
const input = readUserInput((await guardedForm(ctx))!);
|
||||||
@@ -390,7 +390,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
|
|||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
// POST /auth-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.
|
// 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) => {
|
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||||
@@ -402,7 +402,7 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
|||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||||
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||||
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
||||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||||
@@ -415,7 +415,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
|||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
// POST /auth-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) => {
|
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
||||||
@@ -425,7 +425,7 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
|||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
// POST /auth-admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||||
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||||
const { ctx, kratosAdmin } = deps;
|
const { ctx, kratosAdmin } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
// this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start.
|
||||||
//
|
//
|
||||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
||||||
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
||||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
||||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
||||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
@@ -41,7 +41,7 @@ export default definePlugin({
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
|
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
|
||||||
profileMenu: [{ href: MINE_PATH, icon: "i-cal", label: "scheduling.nav.mine", session: true }],
|
profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
|
|||||||
const [major, minor] = host.split(".");
|
const [major, minor] = host.split(".");
|
||||||
for (const file of [
|
for (const file of [
|
||||||
"README.md",
|
"README.md",
|
||||||
"examples/plugins/admin/plugin.ts",
|
"examples/plugins/auth-admin/plugin.ts",
|
||||||
"examples/plugins/scheduling/plugin.ts",
|
"examples/plugins/scheduling/plugin.ts",
|
||||||
"release-tooling/dockerhub-overview.md.tmpl",
|
"release-tooling/dockerhub-overview.md.tmpl",
|
||||||
"views/index.ejs",
|
"views/index.ejs",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
|||||||
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||||
"src/config.ts loadConfig": { lines: 52 },
|
"src/config.ts loadConfig": { lines: 52 },
|
||||||
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||||
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
"examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AstNode {
|
interface AstNode {
|
||||||
|
|||||||
+110
-110
@@ -13,7 +13,7 @@ import type { RelationTuple } from "../auth/keto-client.ts";
|
|||||||
import type { Identity } from "../auth/kratos-admin.ts";
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
import adminManifest from "../../examples/plugins/auth-admin/plugin.ts";
|
||||||
import { createI18n } from "../i18n/runtime.ts";
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
|
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
|
||||||
@@ -21,7 +21,7 @@ import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "..
|
|||||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||||
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
const adminPlugin: Plugin = { ...adminManifest, id: "auth-admin" };
|
||||||
|
|
||||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
@@ -72,22 +72,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
||||||
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/users");
|
await assertAdminGate(url, get, "/auth-admin/users");
|
||||||
|
|
||||||
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||||
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||||
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
||||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
assert.match(await (await get("/dashboard")).text(), /href="\/auth-admin\/users"/);
|
||||||
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||||
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
assert.match(usersOnlyNav, /href="\/auth-admin\/users"/);
|
||||||
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
assert.doesNotMatch(usersOnlyNav, /href="\/auth-admin\/groups"/);
|
||||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/auth-admin\/users"/);
|
||||||
|
|
||||||
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
||||||
// resources don't leak — a users holder is not a groups holder.
|
// resources don't leak — a users holder is not a groups holder.
|
||||||
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
|
assert.equal((await get("/auth-admin/users", ["users:read"])).status, 200);
|
||||||
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
assert.equal((await get("/auth-admin/groups", ["users:read", "users:write"])).status, 403);
|
||||||
const readOnlyPost = await fetch(url + "/admin/users", {
|
const readOnlyPost = await fetch(url + "/auth-admin/users", {
|
||||||
body: `_csrf=${token}&email=nope@example.com`,
|
body: `_csrf=${token}&email=nope@example.com`,
|
||||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -97,44 +97,44 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
||||||
|
|
||||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||||
const listHtml = await (await get("/admin/users")).text();
|
const listHtml = await (await get("/auth-admin/users")).text();
|
||||||
assert.match(listHtml, /ada@example\.com/);
|
assert.match(listHtml, /ada@example\.com/);
|
||||||
assert.match(listHtml, /href="\/admin\/users\/new"/);
|
assert.match(listHtml, /href="\/auth-admin\/users\/new"/);
|
||||||
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
|
assert.doesNotMatch(await (await get("/auth-admin/users?status=inactive")).text(), /ada@example\.com/);
|
||||||
|
|
||||||
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
||||||
assert.match(await (await get("/admin/users/new")).text(), /Create user/);
|
assert.match(await (await get("/auth-admin/users/new")).text(), /Create user/);
|
||||||
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
const created = await post("/auth-admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/users");
|
assert.equal(created.headers.get("location"), "/auth-admin/users");
|
||||||
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
||||||
|
|
||||||
// A create with no CSRF token is refused and creates nothing.
|
// A create with no CSRF token is refused and creates nothing.
|
||||||
const before = store.length;
|
const before = store.length;
|
||||||
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
assert.equal((await post("/auth-admin/users", "email=x%40y.z")).status, 403);
|
||||||
assert.equal(store.length, before);
|
assert.equal(store.length, before);
|
||||||
|
|
||||||
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
// 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.
|
// 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.equal((await post(`/auth-admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||||
assert.ok(store.some((x) => x.id === store[1]!.id));
|
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||||
|
|
||||||
// Edit: email is read-only + prefilled; a post rewrites the name.
|
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||||
const target = store[0]!;
|
const target = store[0]!;
|
||||||
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
const editHtml = await (await get(`/auth-admin/users/${target.id}`)).text();
|
||||||
assert.match(editHtml, /name="email"[^>]*readonly/);
|
assert.match(editHtml, /name="email"[^>]*readonly/);
|
||||||
assert.match(editHtml, /value="ada@example\.com"/);
|
assert.match(editHtml, /value="ada@example\.com"/);
|
||||||
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
const updated = await post(`/auth-admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
||||||
assert.equal(updated.status, 303);
|
assert.equal(updated.status, 303);
|
||||||
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
||||||
|
|
||||||
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
||||||
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
|
await post(`/auth-admin/users/${target.id}/state`, `_csrf=${token}`);
|
||||||
assert.equal(target.state, "inactive");
|
assert.equal(target.state, "inactive");
|
||||||
assert.equal(denylist.isRevoked(target.id, 0), true);
|
assert.equal(denylist.isRevoked(target.id, 0), true);
|
||||||
|
|
||||||
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
|
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
|
||||||
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
const rec = await post(`/auth-admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
||||||
assert.equal(rec.status, 200);
|
assert.equal(rec.status, 200);
|
||||||
const recHtml = await rec.text();
|
const recHtml = await rec.text();
|
||||||
assert.match(recHtml, /Recovery code generated/);
|
assert.match(recHtml, /Recovery code generated/);
|
||||||
@@ -142,22 +142,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
|
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
|
||||||
|
|
||||||
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
||||||
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
|
const confirm = await (await get(`/auth-admin/users/${target.id}/delete`)).text();
|
||||||
assert.match(confirm, /Cancel/);
|
assert.match(confirm, /Cancel/);
|
||||||
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
|
assert.match(confirm, new RegExp(`action="/auth-admin/users/${target.id}/delete"`));
|
||||||
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
const del = await post(`/auth-admin/users/${target.id}/delete`, `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.ok(!store.some((x) => x.id === target.id));
|
assert.ok(!store.some((x) => x.id === target.id));
|
||||||
|
|
||||||
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
||||||
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
assert.equal((await post(`/auth-admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
||||||
assert.ok(store.some((x) => x.id === "admin1"));
|
assert.ok(store.some((x) => x.id === "admin1"));
|
||||||
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
assert.equal((await post(`/auth-admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
assert.equal((await get(`/auth-admin/users/${randomUUID()}`)).status, 404);
|
||||||
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
assert.equal((await get("/auth-admin/users/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
||||||
@@ -174,56 +174,56 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/groups");
|
await assertAdminGate(url, get, "/auth-admin/groups");
|
||||||
|
|
||||||
// List: the existing group shows + the "add" link.
|
// List: the existing group shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/groups")).text();
|
const listHtml = await (await get("/auth-admin/groups")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/groups\/eng"/);
|
assert.match(listHtml, /href="\/auth-admin\/groups\/eng"/);
|
||||||
assert.match(listHtml, /href="\/admin\/groups\/new"/);
|
assert.match(listHtml, /href="\/auth-admin\/groups\/new"/);
|
||||||
|
|
||||||
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
||||||
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
|
assert.match(await (await get("/auth-admin/groups/new")).text(), /Create group/);
|
||||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
const created = await post("/auth-admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
assert.equal(created.headers.get("location"), "/auth-admin/groups/design");
|
||||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
const before = tuples.length;
|
const before = tuples.length;
|
||||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
assert.equal((await post("/auth-admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||||
const duplicate = await post("/admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`);
|
const duplicate = await post("/auth-admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`);
|
||||||
assert.equal(duplicate.status, 400);
|
assert.equal(duplicate.status, 400);
|
||||||
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
|
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
|
||||||
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
assert.equal((await post("/auth-admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||||
assert.equal(tuples.length, before);
|
assert.equal(tuples.length, before);
|
||||||
|
|
||||||
// Detail: lists the current member by email.
|
// Detail: lists the current member by email.
|
||||||
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
assert.match(await (await get("/auth-admin/groups/eng")).text(), /ada@example\.com/);
|
||||||
|
|
||||||
// Add a member, then remove it.
|
// Add a member, then remove it.
|
||||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
await post("/auth-admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
||||||
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
const permissionsBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
assert.match(await (await get("/auth-admin/groups/eng/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
const del = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/groups");
|
assert.equal(del.headers.get("location"), "/auth-admin/groups");
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
||||||
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
||||||
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
|
assert.equal((await get("/auth-admin/groups/Bad%20Name")).status, 404);
|
||||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
assert.equal((await get("/auth-admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||||
@@ -238,7 +238,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
|
||||||
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||||
const edit = await (await get(`/admin/users/${ada}`)).text();
|
const edit = await (await get(`/auth-admin/users/${ada}`)).text();
|
||||||
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
||||||
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
|
|
||||||
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||||
const baseline = baselineOf(edit);
|
const baseline = baselineOf(edit);
|
||||||
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
const saved = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(saved.status, 303);
|
assert.equal(saved.status, 303);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||||
@@ -258,20 +258,20 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
|
|
||||||
// The redirect lands on a page that confirms the save, and shows the new state.
|
// The redirect lands on a page that confirms the save, and shows the new state.
|
||||||
const landing = saved.headers.get("location") ?? "";
|
const landing = saved.headers.get("location") ?? "";
|
||||||
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
|
assert.match(landing, new RegExp(`^/auth-admin/users/${ada}\\?info-msg=permissions-saved$`));
|
||||||
const confirmed = await (await get(landing)).text();
|
const confirmed = await (await get(landing)).text();
|
||||||
assert.match(confirmed, /Permissions saved/);
|
assert.match(confirmed, /Permissions saved/);
|
||||||
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
||||||
|
|
||||||
// A crafted POST can't grant something no plugin declares.
|
// A crafted POST can't grant something no plugin declares.
|
||||||
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||||
|
|
||||||
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
const groupSaved = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||||
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
|
assert.equal(groupSaved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permissions-saved");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,12 +283,12 @@ test("admin permission grants: a save from a stale page is refused, and the page
|
|||||||
const keto = fakeKeto(tuples);
|
const keto = fakeKeto(tuples);
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||||
|
|
||||||
// Another admin grants users:write while this page is open.
|
// Another admin grants users:write while this page is open.
|
||||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
const before = JSON.stringify(tuples);
|
const before = JSON.stringify(tuples);
|
||||||
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
const refused = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
||||||
assert.equal(refused.status, 409);
|
assert.equal(refused.status, 409);
|
||||||
const page = await refused.text();
|
const page = await refused.text();
|
||||||
assert.match(page, /changed while you had the page open/);
|
assert.match(page, /changed while you had the page open/);
|
||||||
@@ -297,18 +297,18 @@ test("admin permission grants: a save from a stale page is refused, and the page
|
|||||||
assert.notEqual(baselineOf(page), stale);
|
assert.notEqual(baselineOf(page), stale);
|
||||||
|
|
||||||
// Saving from the re-rendered page goes through.
|
// Saving from the re-rendered page goes through.
|
||||||
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
const retried = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(retried.status, 303);
|
assert.equal(retried.status, 303);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
||||||
|
|
||||||
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
|
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
|
||||||
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
assert.equal((await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
||||||
|
|
||||||
// Groups are checked the same way.
|
// Groups are checked the same way.
|
||||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
|
const groupStale = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
const groupRefused = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
||||||
assert.equal(groupRefused.status, 409);
|
assert.equal(groupRefused.status, 409);
|
||||||
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
|
||||||
@@ -329,9 +329,9 @@ test("admin permission grants: a Keto failure midway reports a partial save and
|
|||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist();
|
const denylist = createDenylist();
|
||||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||||
|
|
||||||
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
|
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 502);
|
||||||
const page = await res.text();
|
const page = await res.text();
|
||||||
assert.match(page, /stopped part-way/);
|
assert.match(page, /stopped part-way/);
|
||||||
@@ -350,11 +350,11 @@ test("admin permission grants: a save that changes nothing is not refused for a
|
|||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist();
|
const denylist = createDenylist();
|
||||||
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||||
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
const before = JSON.stringify(tuples);
|
const before = JSON.stringify(tuples);
|
||||||
|
|
||||||
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
const repeated = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
assert.equal(repeated.status, 303);
|
assert.equal(repeated.status, 303);
|
||||||
assert.equal(JSON.stringify(tuples), before);
|
assert.equal(JSON.stringify(tuples), before);
|
||||||
assert.equal(denylist.isRevoked(ada, 0), false);
|
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||||
@@ -380,9 +380,9 @@ test("admin permission grants: a partial save is still reported when Keto cannot
|
|||||||
});
|
});
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||||
|
|
||||||
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
|
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 502);
|
||||||
assert.match(await res.text(), /stopped part-way/);
|
assert.match(await res.text(), /stopped part-way/);
|
||||||
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||||
@@ -392,8 +392,8 @@ test("admin permission grants: a partial save is still reported when Keto cannot
|
|||||||
writes = 0;
|
writes = 0;
|
||||||
tuples.length = 0;
|
tuples.length = 0;
|
||||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
|
const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
assert.equal(groupRes.status, 502);
|
assert.equal(groupRes.status, 502);
|
||||||
assert.match(await groupRes.text(), /stopped part-way/);
|
assert.match(await groupRes.text(), /stopped part-way/);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
|
||||||
@@ -403,7 +403,7 @@ test("admin permission grants: a partial save is still reported when Keto cannot
|
|||||||
down = false;
|
down = false;
|
||||||
writes = 0;
|
writes = 0;
|
||||||
tuples.length = 1;
|
tuples.length = 1;
|
||||||
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
|
const again = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/auth-admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
assert.equal(again.status, 502);
|
assert.equal(again.status, 502);
|
||||||
const page = await again.text();
|
const page = await again.text();
|
||||||
assert.match(page, /stopped part-way/);
|
assert.match(page, /stopped part-way/);
|
||||||
@@ -419,15 +419,15 @@ test("admin permission grants: you can't revoke your own permissions, but you ca
|
|||||||
const keto = fakeKeto(tuples);
|
const keto = fakeKeto(tuples);
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
|
const baseline = baselineOf(await (await get("/auth-admin/users/admin1")).text());
|
||||||
|
|
||||||
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
const refused = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
||||||
assert.equal(refused.status, 400);
|
assert.equal(refused.status, 400);
|
||||||
assert.match(await refused.text(), /lock yourself out/);
|
assert.match(await refused.text(), /lock yourself out/);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||||
|
|
||||||
// Granting yourself more is not a lockout, so it goes through.
|
// Granting yourself more is not a lockout, so it goes through.
|
||||||
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
const granted = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(granted.status, 303);
|
assert.equal(granted.status, 303);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||||
});
|
});
|
||||||
@@ -464,32 +464,32 @@ test("admin groups: you can't take away permissions you hold only through a grou
|
|||||||
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
|
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
|
||||||
const baseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
const baseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
// A refusal renders that group's own page and names what you would lose.
|
// A refusal renders that group's own page and names what you would lose.
|
||||||
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
|
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
|
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
|
||||||
assert.match(html, new RegExp(`action="/admin/groups/${group}/members"`));
|
assert.match(html, new RegExp(`action="/auth-admin/groups/${group}/members"`));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unticking users:write on eng would take it from you: refused, nothing revoked.
|
// Unticking users:write on eng would take it from you: refused, nothing revoked.
|
||||||
const before = JSON.stringify(tuples);
|
const before = JSON.stringify(tuples);
|
||||||
await refusedWithMessage(await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
|
await refusedWithMessage(await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
|
||||||
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
|
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
|
||||||
// way; leaving eng also loses what it gets from leads.
|
// way; leaving eng also loses what it gets from leads.
|
||||||
await refusedWithMessage(await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
|
await refusedWithMessage(await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
|
||||||
await refusedWithMessage(await post("/admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
|
await refusedWithMessage(await post("/auth-admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
|
||||||
await refusedWithMessage(await get("/admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
|
await refusedWithMessage(await get("/auth-admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
|
||||||
await refusedWithMessage(await post("/admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
|
await refusedWithMessage(await post("/auth-admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
|
||||||
assert.equal(JSON.stringify(tuples), before, "nothing changed");
|
assert.equal(JSON.stringify(tuples), before, "nothing changed");
|
||||||
|
|
||||||
// You hold groups:write directly too, so unticking it on eng costs you nothing.
|
// You hold groups:write directly too, so unticking it on eng costs you nothing.
|
||||||
assert.equal((await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
|
assert.equal((await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
|
||||||
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
|
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
|
||||||
assert.equal((await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
|
assert.equal((await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
|
||||||
assert.equal((await post("/admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
|
assert.equal((await post("/auth-admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
|
||||||
assert.equal((await post("/admin/groups/design/delete", `_csrf=${token}`)).status, 303);
|
assert.equal((await post("/auth-admin/groups/design/delete", `_csrf=${token}`)).status, 303);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
|
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,43 +507,43 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
|||||||
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||||
const readOnly = ["users:read", "groups:read"];
|
const readOnly = ["users:read", "groups:read"];
|
||||||
|
|
||||||
const list = await (await get("/admin/users", readOnly)).text();
|
const list = await (await get("/auth-admin/users", readOnly)).text();
|
||||||
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
assert.doesNotMatch(list, /href="\/auth-admin\/users\/new"/); // no "New user"
|
||||||
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
||||||
|
|
||||||
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
||||||
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
|
const detail = await (await get(`/auth-admin/users/${ada}`, readOnly)).text();
|
||||||
assert.doesNotMatch(detail, /Save changes/);
|
assert.doesNotMatch(detail, /Save changes/);
|
||||||
assert.doesNotMatch(detail, /Generate recovery code/);
|
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||||
assert.doesNotMatch(detail, /Delete user/);
|
assert.doesNotMatch(detail, /Delete user/);
|
||||||
assert.doesNotMatch(detail, /Save permissions/);
|
assert.doesNotMatch(detail, /Save permissions/);
|
||||||
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||||
|
|
||||||
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
const group = await (await get("/auth-admin/groups/eng", readOnly)).text();
|
||||||
assert.doesNotMatch(group, /Add a member/);
|
assert.doesNotMatch(group, /Add a member/);
|
||||||
assert.doesNotMatch(group, /Delete group/);
|
assert.doesNotMatch(group, /Delete group/);
|
||||||
assert.doesNotMatch(group, /Save permissions/);
|
assert.doesNotMatch(group, /Save permissions/);
|
||||||
|
|
||||||
// The OAuth2-clients screen is held to the same rule.
|
// The OAuth2-clients screen is held to the same rule.
|
||||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
const clientsRes = await get("/auth-admin/clients", ["oauth2-clients:read"]);
|
||||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||||
const clients = await clientsRes.text();
|
const clients = await clientsRes.text();
|
||||||
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||||
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
assert.doesNotMatch(clients, /href="\/auth-admin\/clients\/new"/);
|
||||||
// The detail page is where Delete lives, so check it too and not just the list.
|
// The detail page is where Delete lives, so check it too and not just the list.
|
||||||
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
|
const clientDetail = await (await get("/auth-admin/clients/existing", ["oauth2-clients:read"])).text();
|
||||||
assert.match(clientDetail, /Reporting/);
|
assert.match(clientDetail, /Reporting/);
|
||||||
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
||||||
|
|
||||||
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||||
// rendering a form whose submit would 403.
|
// rendering a form whose submit would 403.
|
||||||
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
|
for (const path of ["/auth-admin/users/new", "/auth-admin/groups/new", `/auth-admin/users/${ada}/delete`, "/auth-admin/groups/eng/delete"]) {
|
||||||
assert.equal((await get(path, readOnly)).status, 403, path);
|
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||||
}
|
}
|
||||||
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
assert.equal((await get("/auth-admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||||
|
|
||||||
// A writer sees the affordances the reader didn't.
|
// A writer sees the affordances the reader didn't.
|
||||||
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
const writable = await (await get(`/auth-admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||||
assert.match(writable, /Save changes/);
|
assert.match(writable, /Save changes/);
|
||||||
assert.match(writable, /Save permissions/);
|
assert.match(writable, /Save permissions/);
|
||||||
});
|
});
|
||||||
@@ -563,20 +563,20 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
|
|||||||
});
|
});
|
||||||
const { get, post, token, url } = await adminHarness(t, { hydra });
|
const { get, post, token, url } = await adminHarness(t, { hydra });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/clients");
|
await assertAdminGate(url, get, "/auth-admin/clients");
|
||||||
|
|
||||||
// List: the existing client shows + the "register" link.
|
// List: the existing client shows + the "register" link.
|
||||||
const listHtml = await (await get("/admin/clients")).text();
|
const listHtml = await (await get("/auth-admin/clients")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
|
assert.match(listHtml, /href="\/auth-admin\/clients\/existing"/);
|
||||||
assert.match(listHtml, /href="\/admin\/clients\/new"/);
|
assert.match(listHtml, /href="\/auth-admin\/clients\/new"/);
|
||||||
assert.match(listHtml, /Reporting/);
|
assert.match(listHtml, /Reporting/);
|
||||||
|
|
||||||
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
||||||
// client and shows the one-time secret + id.
|
// client and shows the one-time secret + id.
|
||||||
const formHtml = await (await get("/admin/clients/new")).text();
|
const formHtml = await (await get("/auth-admin/clients/new")).text();
|
||||||
assert.match(formHtml, /Register client/);
|
assert.match(formHtml, /Register client/);
|
||||||
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
|
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
|
||||||
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
const created = await post("/auth-admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
||||||
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||||
const createdHtml = await created.text();
|
const createdHtml = await created.text();
|
||||||
assert.match(createdHtml, /Client registered/);
|
assert.match(createdHtml, /Client registered/);
|
||||||
@@ -586,26 +586,26 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
|
|||||||
|
|
||||||
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
||||||
const before = store.length;
|
const before = store.length;
|
||||||
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
assert.equal((await post("/auth-admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
||||||
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
assert.equal((await post("/auth-admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
||||||
assert.equal(store.length, before);
|
assert.equal(store.length, before);
|
||||||
|
|
||||||
// Detail: read-only info, never the secret again, a delete control.
|
// Detail: read-only info, never the secret again, a delete control.
|
||||||
const detail = await (await get("/admin/clients/existing")).text();
|
const detail = await (await get("/auth-admin/clients/existing")).text();
|
||||||
assert.match(detail, /reporting\.example\/cb/);
|
assert.match(detail, /reporting\.example\/cb/);
|
||||||
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
||||||
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
||||||
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
|
assert.match(detail, /href="\/auth-admin\/clients\/existing\/delete"/);
|
||||||
|
|
||||||
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
||||||
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
|
assert.match(await (await get("/auth-admin/clients/existing/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
|
const del = await post("/auth-admin/clients/existing/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/clients");
|
assert.equal(del.headers.get("location"), "/auth-admin/clients");
|
||||||
assert.ok(!store.some((c) => c.client_id === "existing"));
|
assert.ok(!store.some((c) => c.client_id === "existing"));
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding doesn't 500.
|
// Unknown id → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
|
assert.equal((await get("/auth-admin/clients/does-not-exist")).status, 404);
|
||||||
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
assert.equal((await get("/auth-admin/clients/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only
|
|||||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||||
]);
|
]);
|
||||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
// The override targets profile-menu entries by the same ids, so one namespace covers both.
|
||||||
|
const profileDup = findConflicts([
|
||||||
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
|
p({ id: "b", profileMenu: [{ href: "/b", id: "dup", label: "B", session: true }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(profileDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
|
||||||
// Sharing a permission across plugins is legitimate → warn, not error.
|
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||||
const permissionDup = findConflicts([
|
const permissionDup = findConflicts([
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|||||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||||
});
|
});
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
const overrideTargets = (plugin: Plugin, push: (id: string) => void): void => {
|
||||||
|
collectNavIds(plugin.nav, push);
|
||||||
|
for (const entry of plugin.profileMenu ?? []) if (entry.id != null) push(entry.id);
|
||||||
|
};
|
||||||
|
collect(plugins, overrideTargets).forEach((owners, id) => {
|
||||||
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -128,11 +128,11 @@ test("a route + nav node may be marked public, or session, and load fine", async
|
|||||||
assert.equal(sess?.nav?.[0]?.session, true);
|
assert.equal(sess?.nav?.[0]?.session, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
test("`auth-admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /auth-admin", async (t) => {
|
||||||
const dir = scaffold(t, { "admin/plugin.ts": full("admin") });
|
const dir = scaffold(t, { "auth-admin/plugin.ts": full("auth-admin") });
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(plugins[0]?.id, "admin");
|
assert.equal(plugins[0]?.id, "auth-admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||||
|
|||||||
@@ -108,8 +108,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`);
|
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
|
// "/" 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. `admin` is likewise free: the
|
// deliberately not reserved — a plugin folder named "home" is legal. `auth-admin` is likewise free:
|
||||||
// admin screens ship as a drop-in plugin mounted at /admin, not a built-in route.
|
// the admin screens ship as a drop-in plugin mounted at /auth-admin, not a built-in route.
|
||||||
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
||||||
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
|
assert.equal(RESERVED_PLUGIN_IDS.has("auth-admin"), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,3 +113,18 @@ test("profile menu entries are gated like nav nodes, translated by their plugin
|
|||||||
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);
|
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);
|
||||||
assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu
|
assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the central override hides and renames a profile-menu entry by its id, which is not rendered", () => {
|
||||||
|
const links = buildPluginChrome({
|
||||||
|
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
|
||||||
|
plugins: [{
|
||||||
|
apiVersion: "1.0.0", id: "account",
|
||||||
|
profileMenu: [
|
||||||
|
{ href: "/account/settings", id: "settings", label: "account.settings", session: true },
|
||||||
|
{ href: "/account/help", id: "help", label: "Help", public: true },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
user: { email: "ada@x.io", id: "u1", permissions: [] },
|
||||||
|
}).user.profileLinks;
|
||||||
|
assert.deepEqual(links, [{ href: "/account/settings", label: "Preferences" }]);
|
||||||
|
});
|
||||||
|
|||||||
+11
-4
@@ -20,7 +20,9 @@ export interface ProfileLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it.
|
// What a plugin's `profileMenu` declares: a link plus the one gate that decides who sees it.
|
||||||
export interface ProfileMenuEntry extends ProfileLink, Gate {}
|
export interface ProfileMenuEntry extends ProfileLink, Gate {
|
||||||
|
id?: string; // targets the entry from config/menu.ts's `hide` and `rename`, as on a nav node; not rendered
|
||||||
|
}
|
||||||
|
|
||||||
// `profileLinks` rides on the user, which every view already hands to the shell, so no view needs a new local.
|
// `profileLinks` rides on the user, which every view already hands to the shell, so no view needs a new local.
|
||||||
export interface ShellUser {
|
export interface ShellUser {
|
||||||
@@ -86,13 +88,18 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
||||||
const user = opts.user ?? null;
|
const user = opts.user ?? null;
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
|
const { hide = [], rename = {} } = opts.menu.override;
|
||||||
return (opts.plugins ?? []).flatMap((plugin) => {
|
return (opts.plugins ?? []).flatMap((plugin) => {
|
||||||
const pluginT = opts.translatorFor?.(plugin.id) ?? t;
|
const pluginT = opts.translatorFor?.(plugin.id) ?? t;
|
||||||
return (plugin.profileMenu ?? []).filter((entry) => allows(entry, user)).map((entry) => ({
|
const shown = (plugin.profileMenu ?? []).filter((entry) => allows(entry, user) && !(entry.id != null && hide.includes(entry.id)));
|
||||||
|
return shown.map((entry) => {
|
||||||
|
const renamed = entry.id != null ? rename[entry.id] : undefined;
|
||||||
|
return {
|
||||||
href: carryLocale(entry.href),
|
href: carryLocale(entry.href),
|
||||||
...(entry.icon != null ? { icon: entry.icon } : {}),
|
...(entry.icon != null ? { icon: entry.icon } : {}),
|
||||||
label: pluginT(entry.label),
|
label: renamed != null ? t(renamed) : pluginT(entry.label),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -23,9 +23,9 @@ export interface NavNode extends Gate {
|
|||||||
// order → hide, then the per-user gate filter runs last.
|
// order → hide, then the per-user gate filter runs last.
|
||||||
export interface NavOverride {
|
export interface NavOverride {
|
||||||
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||||
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id), and profile-menu entries
|
||||||
order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after
|
order?: string[]; // reorder top-level nodes by id; unlisted keep their order, after
|
||||||
rename?: Record<string, string>; // id → replacement label
|
rename?: Record<string, string>; // id → replacement label, for a node or a profile-menu entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavGroupSpec {
|
export interface NavGroupSpec {
|
||||||
|
|||||||
@@ -12,10 +12,8 @@
|
|||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
|
|
||||||
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
|
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
|
||||||
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
||||||
| 58 | 0.5.0 | | owner | **Let an operator hide a plugin's profile-menu entry from `config/menu.ts`.** | 2 | 2 | 2 | 4 | 4 | 7 by 3 | 2.0 |
|
|
||||||
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
||||||
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
||||||
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
|
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
|
||||||
@@ -27,11 +25,6 @@
|
|||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### 2. Rename the admin example plugin to a name that says what it administers.
|
|
||||||
|
|
||||||
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
|
|
||||||
E2E stacks and README move with it.
|
|
||||||
|
|
||||||
### 5. List grants that no installed plugin declares, with a remove action.
|
### 5. List grants that no installed plugin declares, with a remove action.
|
||||||
|
|
||||||
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
|
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
|
||||||
@@ -45,15 +38,6 @@ and deletes in one transaction; confirm that against the Keto version `ory/` pin
|
|||||||
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
|
call to `KetoClient`, which is contract surface (`@plainpages/plugin-api`) and so needs 0.5.0, and the
|
||||||
partial-failure path in the admin example can go.
|
partial-failure path in the admin example can go.
|
||||||
|
|
||||||
### 58. Let an operator hide a plugin's profile-menu entry from `config/menu.ts`.
|
|
||||||
|
|
||||||
A manifest's `profileMenu` entries have no `id`, and the central override (`NavOverride` in
|
|
||||||
`src/ui/nav.ts`) targets sidebar nav nodes only, so hiding or renaming one means forking the plugin,
|
|
||||||
though README → The menu system promises hide and rename for menu items. The fix gives an entry an optional
|
|
||||||
`id`, unique across plugins through a `findConflicts` kind like `nav-id`, and applies `hide` and
|
|
||||||
`rename` to it in `profileLinks` (`src/ui/chrome.ts`). The alternative is a decision recording
|
|
||||||
profile entries as plugin-only, with README saying so. The human chose to keep it in 0.5.0.
|
|
||||||
|
|
||||||
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
||||||
|
|
||||||
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
||||||
|
|||||||
Reference in New Issue
Block a user