feat(19.7-01): account contributions, prompted record actions and backend subject events

- pact.HasAccountContributions compiled at Activate; GET /account/contributions
  and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
  (AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
  Prompt; the record action route accepts payload only for such an action
  (trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
  Value through CRUDService.RecordActionWithValue; a prompt without a label
  fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
  saves that changed role, permissions or superuser, Roles saves that changed
  grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
  TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
  OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
  README, docs/backend/{admin-controllers,users-and-permissions}.md
This commit is contained in:
Jakub Zych
2026-10-22 16:31:29 +02:00
parent 6887abf575
commit 9f14cf603c
28 changed files with 1431 additions and 20 deletions

View File

@@ -526,8 +526,8 @@ The admin merges the contributions at boot, after the target's own record action
- It is offered on the target's preview screen, after the owner's actions, while its `Applies` reports true, and it runs through the same `POST /{controller}/{id}/actions/{action}` route: the record is loaded through the target's `pact.FormExtendQuery` scope with a row lock, and `Run` receives it in `pact.AdminRecordActionInput`.
- Both the target controller's `RequiredPermissions` and the action's own `Permissions` apply, so an action on the Administrators screen needs `backend.manage_users` and its own code. Its `Permissions` must name codes some activated plugin registers.
- A contribution to `summercms.cabana.users` / `cabana.AdministratorsControllerID` is also refused with 403 `user.outranked` when the target administrator's effective grants are not a subset of the caller's. cabana applies this before `Run`, so the contributor does not.
- A record action carries no input beyond the record.
- A record action carries no input beyond the record, unless it declares a `Prompt` (`pact.AdminActionPrompt`: a label, an optional placeholder and comment, and a maximum length in runes, 255 when left at 0). The admin then asks for one text value in the confirm dialog before running the action, the record response describes the input under `prompt` on the action, and the route accepts the body key `payload` as a JSON string for that action only: it is trimmed, must not be empty and must fit the maximum length (422 on `payload` otherwise), and reaches `Run` as `pact.AdminRecordActionInput.Value`. This is how a plugin takes one piece of input on another plugin's screen, such as the login of the site account to link to an administrator; a yes-or-no action needs no prompt. Every other record action keeps refusing a payload.
The start-up stops on an unknown controller ID, a target whose `config_form.yaml` has no `preview` block (record actions are offered on the preview screen), a name the target already registers (its owner's, or an earlier contribution's), a reserved name (`create`, `delete`), a name that is not an identifier, an action without `Run`, or an empty label. Prefix contributed names with your plugin (`acme_...`) so they cannot collide with names the owner adds later.
The start-up stops on an unknown controller ID, a target whose `config_form.yaml` has no `preview` block (record actions are offered on the preview screen), a name the target already registers (its owner's, or an earlier contribution's), a reserved name (`create`, `delete`), a name that is not an identifier, an action without `Run`, an empty label, or a prompt without a label. Prefix contributed names with your plugin (`acme_...`) so they cannot collide with names the owner adds later.
Contributions are compiled only when the admin is active, that is when some plugin registers admin controllers; a plugin that only adds actions does not switch the admin on.

View File

@@ -145,6 +145,8 @@ Every signed-in administrator can edit their own profile on the My account scree
- **Preferences:** the theme (system, light or dark) and the admin language an administrator picks in the SPA are saved through `PUT <prefix>/api/v1/account/preferences` and read with `GET <prefix>/api/v1/account/preferences`; `GET .../auth/me` returns them as `preferences`. The body may hold only `theme` and `locale` (`cabana.AdminPreferencesRequest`); any other key, including `id`, answers 422 on that key and nothing is written. Each present key is saved and an absent key keeps its value, so `{}` answers the current preferences. `theme` must be `system`, `light` or `dark`; `locale` must be `""` (follow the request locale) or one of the admin locales `GET /lang` lists in `meta.locales`; another value or a non-string answers 422 on that field. They are stored under `admin_theme` and `admin_locale` in `backend_users.metadata`, next to the subtitle; the defaults (`system`, `""`) are stored as absent keys, every other key survives a save, and metadata that is not a JSON object is never overwritten (422). Like the rest of My account the routes act only on the token's own administrator and refuse personal API tokens.
- **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes.
- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET <prefix>/api/v1/account/contributions` and runs a button through `POST <prefix>/api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged.
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.
## Managing administrators