From 9f14cf603c7f2b6480a8c4b26e8b4132e8c7cdf4 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 16:31:29 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 273 ++++++++++++++++++ admin/src/api/schema.d.ts | 187 ++++++++++++ docs/backend/admin-controllers.md | 4 +- docs/backend/users-and-permissions.md | 2 + modules/cabana/README.md | 10 +- modules/cabana/account_contributions.go | 214 ++++++++++++++ .../account_contributions_smoke_test.go | 202 +++++++++++++ modules/cabana/account_tokens.go | 3 + modules/cabana/action_extensions.go | 3 + modules/cabana/actions.go | 48 ++- modules/cabana/admin_openapi.go | 61 ++++ modules/cabana/backend_roles_admin.go | 28 ++ modules/cabana/backend_users_admin.go | 35 ++- modules/cabana/contracts.go | 16 + modules/cabana/crud.go | 37 ++- modules/cabana/extension.go | 3 + modules/cabana/http.go | 6 + modules/cabana/openapi_conformance_test.go | 21 ++ modules/cabana/phase10_coverage_test.go | 1 + modules/cabana/phase10_csrf_test.go | 6 +- .../record_action_prompt_postgres_test.go | 76 +++++ .../cabana/record_action_prompt_smoke_test.go | 158 ++++++++++ modules/cabana/schema_types.go | 14 + modules/cabana/security_coverage_test.go | 2 + modules/cabana/subject_events.go | 35 +++ modules/cabana/token_routes_test.go | 2 + modules/phrasebook/backend/lang/en/lang.yaml | 2 + modules/phrasebook/backend/lang/pl/lang.yaml | 2 + 28 files changed, 1431 insertions(+), 20 deletions(-) create mode 100644 modules/cabana/account_contributions.go create mode 100644 modules/cabana/account_contributions_smoke_test.go create mode 100644 modules/cabana/record_action_prompt_postgres_test.go create mode 100644 modules/cabana/record_action_prompt_smoke_test.go create mode 100644 modules/cabana/subject_events.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 89d4f8ad..4e9fe562 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1,6 +1,75 @@ { "components": { "schemas": { + "cabana.AccountActionResultView": { + "properties": { + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "message", + "url" + ], + "type": "object" + }, + "cabana.AccountContributionActionView": { + "properties": { + "confirm": { + "type": "string" + }, + "danger": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "confirm", + "danger", + "label", + "name" + ], + "type": "object" + }, + "cabana.AccountContributionView": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/cabana.AccountContributionActionView" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "description": "Status is the section's line for the signed-in admin; \"\" when the\nsection has none.", + "type": "string" + } + }, + "required": [ + "actions", + "description", + "label", + "name", + "status" + ], + "type": "object" + }, "cabana.AdminAPIToken": { "properties": { "created_at": { @@ -677,6 +746,24 @@ ], "type": "object" }, + "cabana.Envelope-array_cabana_AccountContributionView": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/cabana.AccountContributionView" + }, + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-array_cabana_AdminAPIToken": { "properties": { "data": { @@ -785,6 +872,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_AccountActionResultView": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.AccountActionResultView" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_AdminAPIToken": { "properties": { "data": { @@ -2361,6 +2463,14 @@ }, "name": { "type": "string" + }, + "prompt": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.RecordActionPrompt" + } + ], + "description": "Prompt is present only for an action that asks for one text value in\nits confirm dialog (pact.AdminRecordAction.Prompt); the admin posts the\nvalue as the body key payload, a JSON string." } }, "required": [ @@ -2369,6 +2479,27 @@ ], "type": "object" }, + "cabana.RecordActionPrompt": { + "properties": { + "comment": { + "type": "string" + }, + "label": { + "type": "string" + }, + "max_length": { + "type": "integer" + }, + "placeholder": { + "type": "string" + } + }, + "required": [ + "label", + "max_length" + ], + "type": "object" + }, "cabana.RecordEnvelope": { "properties": { "data": { @@ -3097,6 +3228,148 @@ ] } }, + "/account/contributions": { + "get": { + "description": "Lists the sections plugins contribute to the signed-in admin's My account page: label, description, the status for this admin and the actions they may run that apply to them. Empty when no plugin contributes. Personal API tokens are refused (403 interactive_only).", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-array_cabana_AccountContributionView" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "List My account contributions", + "tags": [ + "admin" + ] + } + }, + "/account/contributions/{name}/actions/{action}": { + "post": { + "description": "Runs one action of a contributed section for the signed-in admin. The action's permissions are checked (403) and its Applies rule again (409 when it no longer applies); the answer is the action's message and an optional URL to open. Personal API tokens are refused (403 interactive_only).", + "parameters": [ + { + "description": "Section name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Action name", + "in": "path", + "name": "action", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AccountActionResultView" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Run a My account contribution action", + "tags": [ + "admin" + ] + } + }, "/account/preferences": { "get": { "description": "The signed-in admin's own UI preferences: theme (system, light or dark; system when not set) and locale (an admin locale code, or \"\" to follow the request locale). Interactive session only (403 interactive_only for personal API tokens).", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 234a36fe..44d8b4fa 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -210,6 +210,152 @@ export interface paths { patch?: never; trace?: never; }; + "/account/contributions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List My account contributions + * @description Lists the sections plugins contribute to the signed-in admin's My account page: label, description, the status for this admin and the actions they may run that apply to them. Empty when no plugin contributes. Personal API tokens are refused (403 interactive_only). + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-array_cabana_AccountContributionView"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/account/contributions/{name}/actions/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Run a My account contribution action + * @description Runs one action of a contributed section for the signed-in admin. The action's permissions are checked (403) and its Applies rule again (409 when it no longer applies); the answer is the action's message and an optional URL to open. Personal API tokens are refused (403 interactive_only). + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Section name */ + name: string; + /** @description Action name */ + action: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AccountActionResultView"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/account/preferences": { parameters: { query?: never; @@ -5362,6 +5508,27 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + "cabana.AccountActionResultView": { + message: string; + url: string; + }; + "cabana.AccountContributionActionView": { + confirm: string; + danger: boolean; + label: string; + name: string; + }; + "cabana.AccountContributionView": { + actions: components["schemas"]["cabana.AccountContributionActionView"][]; + description: string; + label: string; + name: string; + /** + * @description Status is the section's line for the signed-in admin; "" when the + * section has none. + */ + status: string; + }; "cabana.AdminAPIToken": { created_at: string; expired: boolean; @@ -5569,6 +5736,10 @@ export interface components { scripts: string[]; styles: string[]; }; + "cabana.Envelope-array_cabana_AccountContributionView": { + data: components["schemas"]["cabana.AccountContributionView"][]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-array_cabana_AdminAPIToken": { data: components["schemas"]["cabana.AdminAPIToken"][]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -5593,6 +5764,10 @@ export interface components { data: components["schemas"]["cabana.SettingsEntry"][]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_AccountActionResultView": { + data: components["schemas"]["cabana.AccountActionResultView"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_AdminAPIToken": { data: components["schemas"]["cabana.AdminAPIToken"]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -6164,6 +6339,18 @@ export interface components { danger?: boolean; label: string; name: string; + /** + * @description Prompt is present only for an action that asks for one text value in + * its confirm dialog (pact.AdminRecordAction.Prompt); the admin posts the + * value as the body key payload, a JSON string. + */ + prompt?: components["schemas"]["cabana.RecordActionPrompt"]; + }; + "cabana.RecordActionPrompt": { + comment?: string; + label: string; + max_length: number; + placeholder?: string; }; "cabana.RecordEnvelope": { data: components["schemas"]["cabana.AdminRecord"]; diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index 3e89ed13..d5e54290 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -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. diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index ba81c073..e3ccc599 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -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 /api/v1/account/preferences` and read with `GET /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 /api/v1/account/contributions` and runs a button through `POST /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 diff --git a/modules/cabana/README.md b/modules/cabana/README.md index eb6e9fa1..d0f5c08e 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -58,6 +58,9 @@ Use cases: - Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active. - Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin. - Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant. +- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, "The payload must be a string." for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed. +- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot. +- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A listener's error is logged and never fails the save. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery. @@ -84,6 +87,8 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in | GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. | | DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. | | GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. | +| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. An empty list when no plugin contributes. Interactive session only. | +| POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`). Interactive session only, AJAX header required. | | GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. | | GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. | | POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. | @@ -245,6 +250,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.ScopeLimitFor` | Filters stored scope names by one HTTP method into the `bouncer.ScopeLimit` the guard would set for a request with that method, with the known and method-allowed counts. | | `cabana.BackendPrincipal` | Loads an admin principal by id through the backend users provider (grants, superuser flag, backend kind set); nil, nil for a missing or deactivated admin. | | `cabana.AdminAPIToken` / `cabana.AdminAPITokenScope` / `cabana.AdminAPITokenCreated` / `cabana.AdminAPITokenRequest` | JSON shapes of the account token API. | +| `cabana.AccountContributionView` / `cabana.AccountContributionActionView` / `cabana.AccountActionResultView` | JSON shapes of the My account contribution routes: a contributed section with its status and offered actions, one action, and an action's answer (message and optional URL). | | `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | | `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. | | `cabana.FieldTypeAssets` / `cabana.FieldTypesResponse` | One entry of GET `/field-types` (`name`, `element`, `translatable`, `scripts`, `styles`) and the route's answer. | @@ -267,7 +273,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.AdminSingletonResult` / `cabana.AdminSingletonRecord` | Answer and swag annotation of the singleton route. | | `cabana.RepeaterForm` | The `form` of a `type: repeater` field in a form schema: the fields of one row. | | `cabana.FormPreview` | The `preview` object of a form schema: present when the form has a preview screen; `headerPartial` names its status hint partial. | -| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. | +| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label, optional confirm text, the `danger` flag and, for a prompted action only, `prompt`. | +| `cabana.RecordActionPrompt` | The `prompt` of a prompted record action: localized `label`, `placeholder` and `comment`, and `max_length` in runes (255 when the action leaves it at 0). | +| `cabana.CRUDService.RecordActionWithValue` | `cabana.CRUDService.RecordAction` with the trimmed prompt answer handed to `Run` as `pact.AdminRecordActionInput.Value`; the record action route validates the value first. | | `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. | | `cabana.AdminRecordAction` | Swag annotation of the record action route. | | `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. | diff --git a/modules/cabana/account_contributions.go b/modules/cabana/account_contributions.go new file mode 100644 index 00000000..2441056c --- /dev/null +++ b/modules/cabana/account_contributions.go @@ -0,0 +1,214 @@ +package cabana + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/towel" + "gorm.io/gorm" +) + +// accountContributionCatalog holds the My account sections plugins +// contribute (pact.HasAccountContributions), in activation order. +type accountContributionCatalog struct { + ordered []compiledAccountContribution + byName map[string]*compiledAccountContribution +} + +// compiledAccountContribution is one contributed section with its owner. +type compiledAccountContribution struct { + pluginID string + section pact.AccountContribution + actions map[string]pact.AccountAction +} + +// compileAccountContributions collects pact.HasAccountContributions from +// plugins into reg, in activation order. Every failure stops boot with a +// message naming the plugin and the section: a name that is not an +// identifier, a name another plugin already contributed, an empty label, an +// action whose name is not an identifier or repeats within the section, a +// nil Run or an empty action label. +func compileAccountContributions(reg *Registry, plugins []party.Plugin) error { + if reg == nil { + return fmt.Errorf("cabana: registry is nil") + } + cat := &accountContributionCatalog{byName: map[string]*compiledAccountContribution{}} + for _, plugin := range plugins { + if plugin == nil { + continue + } + src, ok := plugin.(pact.HasAccountContributions) + if !ok || src == nil { + continue + } + pluginID := plugin.ID() + for _, section := range src.AccountContributions() { + prefix := fmt.Sprintf("cabana: plugin %s contributes account section %q", pluginID, section.Name) + if !identifier(section.Name) { + return fmt.Errorf("%s: the name is not an identifier", prefix) + } + if taken, ok := cat.byName[section.Name]; ok { + return fmt.Errorf("%s: plugin %s already contributes a section with that name", prefix, taken.pluginID) + } + if strings.TrimSpace(section.Label) == "" { + return fmt.Errorf("%s: it needs a label", prefix) + } + compiled := &compiledAccountContribution{pluginID: pluginID, section: section, actions: map[string]pact.AccountAction{}} + for _, action := range section.Actions { + if !identifier(action.Name) { + return fmt.Errorf("%s: action name %q is not an identifier", prefix, action.Name) + } + if _, dup := compiled.actions[action.Name]; dup { + return fmt.Errorf("%s: duplicate action %q", prefix, action.Name) + } + if action.Run == nil { + return fmt.Errorf("%s: action %q has no Run function", prefix, action.Name) + } + if strings.TrimSpace(action.Label) == "" { + return fmt.Errorf("%s: action %q needs a label", prefix, action.Name) + } + compiled.actions[action.Name] = action + } + cat.ordered = append(cat.ordered, *compiled) + cat.byName[section.Name] = &cat.ordered[len(cat.ordered)-1] + } + } + // The slice may have grown: point the map at the final backing array. + for i := range cat.ordered { + cat.byName[cat.ordered[i].section.Name] = &cat.ordered[i] + } + reg.accountContributions = cat + return nil +} + +// accountContributionsList serves GET /account/contributions: every +// contributed section with its status for the signed-in administrator and +// the actions they may run that apply to them, labels translated. +func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Request) { + principal := accountPrincipal(w, r) + if principal == nil { + return + } + ctx := r.Context() + tr := s.translator() + out := []AccountContributionView{} + if s.reg != nil && s.reg.accountContributions != nil { + for _, item := range s.reg.accountContributions.ordered { + view := AccountContributionView{ + Name: item.section.Name, + Label: translateKey(ctx, tr, item.section.Label), + Description: translateKey(ctx, tr, item.section.Description), + Actions: []AccountContributionActionView{}, + } + if item.section.Status != nil { + status, err := item.section.Status(ctx, principal.ID) + if err != nil { + moduleLog.ErrorContext(ctx, "cabana: account contribution status failed", "section", item.section.Name, "plugin", item.pluginID, "error", err) + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + view.Status = translateKey(ctx, tr, status) + } + for _, action := range item.section.Actions { + if !Allows(principal, action.Permissions) { + continue + } + if action.Applies != nil { + applies, err := action.Applies(ctx, principal.ID) + if err != nil { + moduleLog.ErrorContext(ctx, "cabana: account contribution applies failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err) + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + if !applies { + continue + } + } + view.Actions = append(view.Actions, AccountContributionActionView{ + Name: action.Name, + Label: translateKey(ctx, tr, action.Label), + Confirm: translateKey(ctx, tr, action.Confirm), + Danger: action.Danger, + }) + } + out = append(out, view) + } + } + WriteData(w, http.StatusOK, out, map[string]any{}) +} + +// accountContributionAction serves POST /account/contributions/{name}/actions/{action}: +// an unknown section or action is 404, a missing permission 403, an action +// that no longer applies 409; then Run runs for the signed-in administrator +// inside a transaction when the admin has a database (the context carries +// it, as for record actions) and its message and URL are answered. +func (s *service) accountContributionAction(w http.ResponseWriter, r *http.Request) { + principal := accountPrincipal(w, r) + if principal == nil { + return + } + var item *compiledAccountContribution + if s.reg != nil && s.reg.accountContributions != nil { + item = s.reg.accountContributions.byName[r.PathValue("name")] + } + if item == nil { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + action, ok := item.actions[r.PathValue("action")] + if !ok { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + if !s.allowAction(w, r, action.Permissions) { + return + } + tr := s.translator() + ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr)) + run := func(ctx context.Context) (pact.AccountActionResult, error) { + if action.Applies != nil { + applies, err := action.Applies(ctx, principal.ID) + if err != nil { + return pact.AccountActionResult{}, err + } + if !applies { + return pact.AccountActionResult{}, actionConflict{} + } + } + return action.Run(ctx, pact.AccountActionInput{AdminID: principal.ID}) + } + var result pact.AccountActionResult + var err error + if db, dbErr := s.db(); dbErr == nil { + err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + result, err = run(withTx(ctx, tx)) + return err + }) + } else { + result, err = run(ctx) + } + if err != nil { + // A refusal the plugin authored (403, 422) or a stale action (409) + // is answered as is; anything else is the plugin's failure, logged + // here and answered as an opaque 500. + var stale actionConflict + var refused *ForbiddenError + var invalid *ValidationError + if !errors.As(err, &stale) && !errors.As(err, &refused) && !errors.As(err, &invalid) { + moduleLog.ErrorContext(ctx, "cabana: account contribution action failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err) + } + writeCRUDError(w, localizeControllerError(ctx, tr, err)) + return + } + moduleLog.InfoContext(ctx, "cabana: account contribution action", "admin_id", principal.ID, "section", item.section.Name, "action", action.Name) + WriteData(w, http.StatusOK, AccountActionResultView{ + Message: translateKey(ctx, tr, result.Message), + URL: result.URL, + }, nil) +} diff --git a/modules/cabana/account_contributions_smoke_test.go b/modules/cabana/account_contributions_smoke_test.go new file mode 100644 index 00000000..940caf12 --- /dev/null +++ b/modules/cabana/account_contributions_smoke_test.go @@ -0,0 +1,202 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// contributor is a plugin that contributes one My account section (phase +// 19.7 D-04 part 1, planner decision P-02). +type contributor struct { + id string + sections []pact.AccountContribution +} + +func (p contributor) ID() string { return p.id } +func (contributor) Requires() []string { return nil } +func (contributor) Register(*backpack.App) error { return nil } +func (contributor) Boot(*backpack.App) error { return nil } +func (p contributor) AccountContributions() []pact.AccountContribution { return p.sections } + +// TestAccountContributionsSmoke: a contributed section is listed with its +// status and the actions that apply, an action runs for the signed-in +// administrator and answers its URL, personal tokens are refused, and a +// duplicate section name across plugins fails boot. +func TestAccountContributionsSmoke(t *testing.T) { + var ran []uint + section := pact.AccountContribution{ + Name: "site_link", Label: "acme::lang.site_link", Description: "acme::lang.site_link_hint", + Status: func(_ context.Context, adminID uint) (string, error) { return "linked as admin", nil }, + Actions: []pact.AccountAction{ + { + Name: "open", Label: "acme::lang.open", Confirm: "acme::lang.open_confirm", Danger: true, + Run: func(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) { + ran = append(ran, in.AdminID) + return pact.AccountActionResult{Message: "acme::lang.opened", URL: "https://blog.example.com/link"}, nil + }, + }, + { + Name: "hidden", Label: "acme::lang.hidden", + Applies: func(context.Context, uint) (bool, error) { return false, nil }, + Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { + return pact.AccountActionResult{}, errors.New("must not run") + }, + }, + { + Name: "locked", Label: "acme::lang.locked", Permissions: []string{"acme.links.manage"}, + Run: func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { + return pact.AccountActionResult{Message: "acme::lang.locked_done"}, nil + }, + }, + }, + } + reg := &Registry{byID: map[string]*CompiledController{}} + if err := compileAccountContributions(reg, []party.Plugin{contributor{id: "acme.links", sections: []pact.AccountContribution{section}}}); err != nil { + t.Fatal(err) + } + svc := &service{app: backpack.New(nil), reg: reg} + admin := &bouncer.Principal{ID: 42, Backend: true} + + request := func(method, path string, principal *bouncer.Principal) *http.Request { + req := httptest.NewRequest(method, path, strings.NewReader("{}")) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.SetPathValue("name", "site_link") + if strings.Contains(path, "/actions/") { + req.SetPathValue("action", path[strings.LastIndex(path, "/")+1:]) + } + if principal != nil { + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + } + return req + } + list := func(principal *bouncer.Principal) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + interactiveOnly(svc.accountContributionsList)(rec, request(http.MethodGet, "/account/contributions", principal)) + return rec + } + run := func(action string, principal *bouncer.Principal) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + interactiveOnly(requireAjax(svc.accountContributionAction))(rec, request(http.MethodPost, "/account/contributions/site_link/actions/"+action, principal)) + return rec + } + + // The list: one section, its status, the open action; hidden (Applies + // false) and locked (missing permission) are left out. + rec := list(admin) + if rec.Code != http.StatusOK { + t.Fatalf("list = %d %s", rec.Code, rec.Body.String()) + } + var listed struct { + Data []AccountContributionView `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil { + t.Fatal(err) + } + if len(listed.Data) != 1 || listed.Data[0].Name != "site_link" || listed.Data[0].Label != "acme::lang.site_link" || listed.Data[0].Status != "linked as admin" { + t.Fatalf("listed = %+v", listed.Data) + } + if actions := listed.Data[0].Actions; len(actions) != 1 || actions[0].Name != "open" || !actions[0].Danger || actions[0].Confirm != "acme::lang.open_confirm" { + t.Fatalf("actions = %+v", listed.Data[0].Actions) + } + // A superuser holds every permission: locked appears for them. + super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} + if err := json.Unmarshal(list(super).Body.Bytes(), &listed); err != nil || len(listed.Data[0].Actions) != 2 { + t.Fatalf("superuser actions = %+v err=%v", listed.Data, err) + } + + // Running the action answers its message and URL for the signed-in admin. + rec = run("open", admin) + if rec.Code != http.StatusOK { + t.Fatalf("run = %d %s", rec.Code, rec.Body.String()) + } + var result struct { + Data AccountActionResultView `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Data.URL != "https://blog.example.com/link" || result.Data.Message != "acme::lang.opened" || len(ran) != 1 || ran[0] != 42 { + t.Fatalf("result = %+v ran=%v", result.Data, ran) + } + if rec := run("hidden", admin); rec.Code != http.StatusConflict { + t.Fatalf("action that does not apply = %d %s", rec.Code, rec.Body.String()) + } + if rec := run("locked", admin); rec.Code != http.StatusForbidden { + t.Fatalf("action without its permission = %d %s", rec.Code, rec.Body.String()) + } + if rec := run("missing", admin); rec.Code != http.StatusNotFound { + t.Fatalf("unknown action = %d %s", rec.Code, rec.Body.String()) + } + if rec := run("open", nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("no principal = %d %s", rec.Code, rec.Body.String()) + } + if rec := run("open", &bouncer.Principal{ID: 7}); rec.Code != http.StatusUnauthorized { + t.Fatalf("frontend principal = %d %s", rec.Code, rec.Body.String()) + } + + // Personal API tokens are refused: a token credential and a raw g15_ bearer. + tokenReq := request(http.MethodGet, "/account/contributions", admin) + tokenReq = tokenReq.WithContext(bouncer.WithCredential(tokenReq.Context(), &BackendAPIToken{ID: 3})) + rec = httptest.NewRecorder() + interactiveOnly(svc.accountContributionsList)(rec, tokenReq) + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") { + t.Fatalf("token credential = %d %s", rec.Code, rec.Body.String()) + } + bearerReq := request(http.MethodPost, "/account/contributions/site_link/actions/open", admin) + bearerReq.Header.Set("Authorization", "Bearer "+APITokenPrefix+strings.Repeat("0", 40)) + rec = httptest.NewRecorder() + interactiveOnly(requireAjax(svc.accountContributionAction))(rec, bearerReq) + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "interactive_only") || len(ran) != 1 { + t.Fatalf("raw bearer = %d %s ran=%v", rec.Code, rec.Body.String(), ran) + } + // The POST needs the AJAX header (CSRF). + plain := request(http.MethodPost, "/account/contributions/site_link/actions/open", admin) + plain.Header.Del("X-Requested-With") + rec = httptest.NewRecorder() + interactiveOnly(requireAjax(svc.accountContributionAction))(rec, plain) + if rec.Code != http.StatusForbidden || len(ran) != 1 { + t.Fatalf("POST without the AJAX header = %d ran=%v", rec.Code, ran) + } + + // Boot refuses a section name another plugin already contributed, and + // the other shapes the contract names. + dup := []party.Plugin{ + contributor{id: "acme.links", sections: []pact.AccountContribution{section}}, + contributor{id: "acme.other", sections: []pact.AccountContribution{{Name: "site_link", Label: "x", Actions: nil}}}, + } + err := compileAccountContributions(&Registry{}, dup) + if err == nil || !strings.Contains(err.Error(), "acme.other") || !strings.Contains(err.Error(), "acme.links") { + t.Fatalf("duplicate section: %v", err) + } + for name, bad := range map[string]pact.AccountContribution{ + "non-identifier name": {Name: "site-link", Label: "x"}, + "empty label": {Name: "site_link", Label: " "}, + "bad action name": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "no!", Label: "x", Run: section.Actions[0].Run}}}, + "nil run": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Label: "x"}}}, + "empty action label": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Run: section.Actions[0].Run}}}, + "duplicate action": {Name: "site_link", Label: "x", Actions: []pact.AccountAction{{Name: "open", Label: "x", Run: section.Actions[0].Run}, {Name: "open", Label: "y", Run: section.Actions[0].Run}}}, + } { + err := compileAccountContributions(&Registry{}, []party.Plugin{contributor{id: "acme.bad", sections: []pact.AccountContribution{bad}}}) + if err == nil || !strings.HasPrefix(err.Error(), "cabana: plugin acme.bad contributes account section") { + t.Fatalf("%s: err = %v", name, err) + } + } + + // No contributor: an empty list, never null, and nothing else changes. + empty := &service{app: backpack.New(nil), reg: &Registry{}} + rec = httptest.NewRecorder() + empty.accountContributionsList(rec, request(http.MethodGet, "/account/contributions", admin)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data":[]`) { + t.Fatalf("empty list = %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/modules/cabana/account_tokens.go b/modules/cabana/account_tokens.go index 36d1cc3c..0c8f0dd7 100644 --- a/modules/cabana/account_tokens.go +++ b/modules/cabana/account_tokens.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" ) @@ -162,6 +163,8 @@ func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } + // A revoked token ends what it opened elsewhere (an MCP session). + fireSubjectChanged(r.Context(), s.app, pact.MCPSubjectRevoked, principal.ID) WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil) } diff --git a/modules/cabana/action_extensions.go b/modules/cabana/action_extensions.go index b9760abb..93101de8 100644 --- a/modules/cabana/action_extensions.go +++ b/modules/cabana/action_extensions.go @@ -91,6 +91,9 @@ func checkContributedAction(cc *CompiledController, pluginID, controller string, if strings.TrimSpace(action.Label) == "" { return fmt.Errorf("%s: it needs a label", prefix) } + if action.Prompt != nil && strings.TrimSpace(action.Prompt.Label) == "" { + return fmt.Errorf("%s: its prompt needs a label", prefix) + } if _, taken := cc.RecordActions[action.Name]; taken { owner := cc.PluginID if contributor, ok := cc.contributed[action.Name]; ok { diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go index 0b22b7ea..6a2306dd 100644 --- a/modules/cabana/actions.go +++ b/modules/cabana/actions.go @@ -6,9 +6,13 @@ import ( "errors" "io" "net/http" + "strconv" + "strings" + "unicode/utf8" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -210,16 +214,21 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) { writeCRUDError(w, err) return } - if in.RecordID != nil || in.Values != nil || len(in.Payload) > 0 { + if in.RecordID != nil || in.Values != nil || (action.Prompt == nil && len(in.Payload) > 0) { writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id, values or payload."}}}) return } + value, err := promptValue(r.Context(), s.translator(), action.Prompt, in.Payload) + if err != nil { + writeCRUDError(w, err) + return + } svc, err := s.crud() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - result, err := svc.RecordAction(r.Context(), cc, id, name) + result, err := svc.RecordActionWithValue(r.Context(), cc, id, name, value) if err != nil { writeCRUDError(w, err) return @@ -233,6 +242,41 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) { }) } +// Phrase keys of the prompt value refusals (backend::lang.form). +const ( + promptRequiredKey = "backend::lang.form.action_prompt_required" + promptTooLongKey = "backend::lang.form.action_prompt_too_long" +) + +// promptValue decodes the prompt answer of a prompted record action from +// the posted payload: a JSON string, trimmed, not empty and at most the +// prompt's MaxLength runes, else a 422 on payload. An action without a +// Prompt (nil) answers "" without reading the payload, which the route has +// already refused. +func promptValue(ctx context.Context, tr *phrasebook.Translator, prompt *pact.AdminActionPrompt, payload json.RawMessage) (string, error) { + if prompt == nil { + return "", nil + } + refuse := func(message string) error { + return &ValidationError{Details: map[string]any{"payload": []string{message}}} + } + if len(payload) == 0 { + return "", refuse(translateKey(ctx, tr, promptRequiredKey)) + } + var raw string + if err := json.Unmarshal(payload, &raw); err != nil { + return "", refuse("The payload must be a string.") + } + value := strings.TrimSpace(raw) + if value == "" { + return "", refuse(translateKey(ctx, tr, promptRequiredKey)) + } + if max := promptMaxLength(prompt); utf8.RuneCountInString(value) > max { + return "", refuse(strings.ReplaceAll(translateKey(ctx, tr, promptTooLongKey), ":max", strconv.Itoa(max))) + } + return value, nil +} + // recordActionOf returns the registered record action a form declares under // name in recordActions. func recordActionOf(cc *CompiledController, name string) (pact.AdminRecordAction, bool) { diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 2b8edd08..49034c60 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -330,6 +330,67 @@ func AdminAccountTokenCreate() {} // @Router /account/tokens/{id} [delete] func AdminAccountTokenRevoke() {} +// AccountContributionView is one section a plugin contributes to My account +// (pact.AccountContribution), for the signed-in admin: translated label, +// description and status, and the actions they may run that apply to them. +type AccountContributionView struct { + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description"` + // Status is the section's line for the signed-in admin; "" when the + // section has none. + Status string `json:"status"` + Actions []AccountContributionActionView `json:"actions"` +} + +// AccountContributionActionView is one button of a contributed section: the +// name posted to .../actions/{action}, its translated label, an optional +// confirmation text and whether the confirm dialog takes the danger variant. +type AccountContributionActionView struct { + Name string `json:"name"` + Label string `json:"label"` + Confirm string `json:"confirm"` + Danger bool `json:"danger"` +} + +// AccountActionResultView is an account action's answer: a translated +// message for the toast and an optional URL the admin opens in a new tab. +type AccountActionResultView struct { + Message string `json:"message"` + URL string `json:"url"` +} + +// AdminAccountContributions documents GET /account/contributions. +// +// @Summary List My account contributions +// @Description Lists the sections plugins contribute to the signed-in admin's My account page: label, description, the status for this admin and the actions they may run that apply to them. Empty when no plugin contributes. Personal API tokens are refused (403 interactive_only). +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Success 200 {object} Envelope[[]AccountContributionView] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Router /account/contributions [get] +func AdminAccountContributions() {} + +// AdminAccountContributionAction documents POST /account/contributions/{name}/actions/{action}. +// +// @Summary Run a My account contribution action +// @Description Runs one action of a contributed section for the signed-in admin. The action's permissions are checked (403) and its Applies rule again (409 when it no longer applies); the answer is the action's message and an optional URL to open. Personal API tokens are refused (403 interactive_only). +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param name path string true "Section name" +// @Param action path string true "Action name" +// @Success 200 {object} Envelope[AccountActionResultView] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /account/contributions/{name}/actions/{action} [post] +func AdminAccountContributionAction() {} + // AdminAccountTokenScopes documents GET /account/tokens/scopes. // // @Summary List available API token scopes diff --git a/modules/cabana/backend_roles_admin.go b/modules/cabana/backend_roles_admin.go index fb3553e1..38e58117 100644 --- a/modules/cabana/backend_roles_admin.go +++ b/modules/cabana/backend_roles_admin.go @@ -35,6 +35,7 @@ var ( _ pact.FormRecordTitle = rolesAdmin{} _ pact.FormBeforeCreate = rolesAdmin{} _ pact.FormBeforeUpdate = rolesAdmin{} + _ pact.FormAfterUpdate = rolesAdmin{} _ pact.FormBeforeDelete = rolesAdmin{} _ PermissionEditorProvider = rolesAdmin{} _ PermissionRecordLockProvider = rolesAdmin{} @@ -151,10 +152,37 @@ func (rolesAdmin) AdminSetPermissionValues(_ context.Context, field string, reco if err != nil { return err } + if !role.priorLoaded { + role.priorPermissions = role.Permissions + role.priorLoaded = true + } role.Permissions = encoded return nil } +// FormAfterUpdate fires pact.MCPSubjectChanged (permissions) for every +// administrator holding the role when its grants changed, their ids read +// through the save's transaction. The save itself is unchanged. +func (c rolesAdmin) FormAfterUpdate(ctx context.Context, model any) error { + role, err := asRole(model) + if err != nil { + return err + } + if !role.priorLoaded || role.priorPermissions == role.Permissions { + return nil + } + db, err := writeDB(ctx) + if err != nil { + return err + } + var holders []uint + if err := db.Unscoped().Model(&BackendUser{}).Where("role_id = ?", role.ID).Pluck("id", &holders).Error; err != nil { + return err + } + fireSubjectChanged(ctx, c.screens.application(), pact.MCPSubjectPermissions, holders...) + return nil +} + // refuseCodeDeclaredCode refuses a submitted code that code-declared grants // (pact.Permission.Roles) attach to when it is not the stored code (empty // on create). A role never becomes a code-declared system role through this diff --git a/modules/cabana/backend_users_admin.go b/modules/cabana/backend_users_admin.go index e3181c74..d71b9d53 100644 --- a/modules/cabana/backend_users_admin.go +++ b/modules/cabana/backend_users_admin.go @@ -216,7 +216,7 @@ func (c usersAdmin) FormBeforeDelete(ctx context.Context, model any) error { // backend user is never hard-deleted, so tokens, linked_by references and // attachments keep valid foreign keys (D-10). Personal API tokens stop // because the guard refuses a deleted administrator. -func (usersAdmin) FormAfterDelete(ctx context.Context, model any) error { +func (c usersAdmin) FormAfterDelete(ctx context.Context, model any) error { user, err := asBackendUser(model) if err != nil { return err @@ -226,9 +226,13 @@ func (usersAdmin) FormAfterDelete(ctx context.Context, model any) error { return errors.New("cabana: administrators: a delete needs its transaction") } cutoff := revocationCutoff() - return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). + if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). Model(&BackendUser{}).Where("id = ?", user.ID). - Update("tokens_valid_after", cutoff).Error + Update("tokens_valid_after", cutoff).Error; err != nil { + return err + } + fireSubjectChanged(ctx, c.application(), pact.MCPSubjectDeleted, user.ID) + return nil } // usersLifecycle is one lifecycle action of the Administrators screen, @@ -276,8 +280,14 @@ func (c usersAdmin) lifecycles() []usersLifecycle { if err := guardLastSuperuser(ctx, user); err != nil { return err } - return db.Model(&BackendUser{}).Where("id = ?", user.ID). - Updates(map[string]any{"is_activated": false, "tokens_valid_after": revocationCutoff()}).Error + if err := db.Model(&BackendUser{}).Where("id = ?", user.ID). + Updates(map[string]any{"is_activated": false, "tokens_valid_after": revocationCutoff()}).Error; err != nil { + return err + } + // A deactivated administrator can no longer act anywhere, + // an MCP session included. + fireSubjectChanged(ctx, c.application(), pact.MCPSubjectDeleted, user.ID) + return nil }, }, { @@ -767,6 +777,8 @@ func (c usersAdmin) FormBeforeUpdate(ctx context.Context, model any) error { } else { user.priorRoleID = nil } + user.priorPermissions = user.Permissions + user.priorSuperuser = user.IsSuperuser user.priorLoaded = true if !self { if err := guardOutranked(ctx, c.registry(), principal, user); err != nil { @@ -812,19 +824,24 @@ func (c usersAdmin) FormBeforeUpdate(ctx context.Context, model any) error { // FormAfterUpdate refuses a change of the editor's own role (D-08). The // submitted role is assigned after FormBeforeUpdate, so it is compared here, -// with the row written: the refusal rolls the save back. +// with the row written: the refusal rolls the save back. A save that changed +// the administrator's role, own permissions or superuser flag then fires +// pact.MCPSubjectChanged (permissions) for them. func (c usersAdmin) FormAfterUpdate(ctx context.Context, model any) error { user, err := asBackendUser(model) if err != nil { return err } - principal, _ := bouncer.User(ctx) - if !user.priorLoaded || principal == nil || principal.ID != user.ID { + if !user.priorLoaded { return nil } - if !sameRoleID(user.priorRoleID, user.RoleID) { + principal, _ := bouncer.User(ctx) + if principal != nil && principal.ID == user.ID && !sameRoleID(user.priorRoleID, user.RoleID) { return &ForbiddenError{Message: usersLang + "self_privileged", Details: map[string]any{usersRoleField: []string{usersLang + "self_privileged"}}} } + if !sameRoleID(user.priorRoleID, user.RoleID) || user.priorPermissions != user.Permissions || user.priorSuperuser != user.IsSuperuser { + fireSubjectChanged(ctx, c.application(), pact.MCPSubjectPermissions, user.ID) + } return nil } diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index be980b25..4a7fa9e4 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -25,6 +25,13 @@ type BackendUserRole struct { IsSystem bool `gorm:"column:is_system"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` + + // priorPermissions is the stored grants the Roles screen saw before the + // permission editor wrote the submitted ones (priorLoaded set), so the + // save knows whether the role's holders changed what they may do + // (pact.MCPSubjectChanged). Unexported: GORM skips it. + priorPermissions string + priorLoaded bool } func (BackendUserRole) TableName() string { return "backend_user_roles" } @@ -79,6 +86,11 @@ type BackendUser struct { // assignment. Unexported: GORM and the admin's field binding skip it. priorRoleID *uint priorLoaded bool + // priorPermissions and priorSuperuser are the stored overrides and flag + // at the same moment, so the save can tell whether what the + // administrator may do changed (pact.MCPSubjectChanged). + priorPermissions string + priorSuperuser bool } func (BackendUser) TableName() string { return "backend_users" } @@ -265,6 +277,10 @@ type Registry struct { // tokenScopes is the compiled personal API token catalog (* , admin:read, // then plugin scopes in activation order). tokenScopes *tokenScopeCatalog + // accountContributions are the My account sections plugins contribute + // (pact.HasAccountContributions), in activation order; nil before + // compileAccountContributions. + accountContributions *accountContributionCatalog } // Get returns the compiled controller for a D-09 id (vendor.plugin.controller). diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 290c1d2c..e7696ec2 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -569,23 +569,54 @@ func (s CRUDService) offeredRecordActions(ctx context.Context, cc *CompiledContr continue } } - out = append(out, RecordAction{ + offered := RecordAction{ Name: name, Label: translateKey(ctx, s.tr, action.Label), Confirm: translateKey(ctx, s.tr, action.Confirm), Danger: action.Danger, - }) + } + if action.Prompt != nil { + offered.Prompt = &RecordActionPrompt{ + Label: translateKey(ctx, s.tr, action.Prompt.Label), + Placeholder: translateKey(ctx, s.tr, action.Prompt.Placeholder), + Comment: translateKey(ctx, s.tr, action.Prompt.Comment), + MaxLength: promptMaxLength(action.Prompt), + } + } + out = append(out, offered) } return out, nil } +// defaultPromptMaxLength is the longest prompt value in runes when a +// pact.AdminActionPrompt leaves MaxLength at 0. +const defaultPromptMaxLength = 255 + +// promptMaxLength is the prompt's cap in runes, defaultPromptMaxLength for 0 +// or less. +func promptMaxLength(prompt *pact.AdminActionPrompt) int { + if prompt == nil || prompt.MaxLength <= 0 { + return defaultPromptMaxLength + } + return prompt.MaxLength +} + // RecordAction runs the declared record action name on one record in a // transaction. The record is loaded through the controller's FormExtendQuery // scope with a row lock: a missing or out-of-scope id is not found. The // action's Applies is checked again inside the transaction, and an action // that does not apply to the record's current state conflicts. A name the // form does not declare, or the controller does not register, is not found. +// The action receives no prompt value; RecordActionWithValue carries one. func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, id any, name string) (AdminActionResult, error) { + return s.RecordActionWithValue(ctx, cc, id, name, "") +} + +// RecordActionWithValue is RecordAction with the trimmed answer of a +// prompted action's text input, handed to Run as +// pact.AdminRecordActionInput.Value (empty for an action without a Prompt; +// the record action route validates it before calling this). +func (s CRUDService) RecordActionWithValue(ctx context.Context, cc *CompiledController, id any, name, value string) (AdminActionResult, error) { if s.DB == nil { return AdminActionResult{}, errors.New("cabana: database is not configured") } @@ -623,7 +654,7 @@ func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, i return actionConflict{} } } - out, err := action.Run(ctx, pact.AdminRecordActionInput{RecordID: uint64(pkUint(target)), Record: target}) + out, err := action.Run(ctx, pact.AdminRecordActionInput{RecordID: uint64(pkUint(target)), Record: target, Value: value}) if err != nil { return actionFailure(cc, "record action", name, err) } diff --git a/modules/cabana/extension.go b/modules/cabana/extension.go index c5eab056..22adb86c 100644 --- a/modules/cabana/extension.go +++ b/modules/cabana/extension.go @@ -423,6 +423,9 @@ func compileRecordActions(ctl pact.AdminController) (map[string]pact.AdminRecord if action.Run == nil { return nil, fmt.Errorf("record action %s has no Run function", action.Name) } + if action.Prompt != nil && strings.TrimSpace(action.Prompt.Label) == "" { + return nil, fmt.Errorf("record action %s has a prompt without a label", action.Name) + } out[action.Name] = action } return out, nil diff --git a/modules/cabana/http.go b/modules/cabana/http.go index d1d1e061..078c2f7b 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -137,6 +137,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err := compileActionExtensions(reg, plugins); err != nil { return nil, err } + if err := compileAccountContributions(reg, plugins); err != nil { + return nil, err + } if err := compileContributions(reg, plugins); err != nil { return nil, err } @@ -293,6 +296,9 @@ func (s *service) mount(r pact.Router) { g.Post("/account/tokens", interactiveOnly(requireAjax(s.accountTokenCreate))) g.Get("/account/tokens/scopes", interactiveOnly(s.accountTokenScopes)) g.Delete("/account/tokens/{id}", interactiveOnly(requireAjax(s.accountTokenRevoke))) + // Sections plugins contribute to My account (pact.HasAccountContributions). + g.Get("/account/contributions", interactiveOnly(s.accountContributionsList)) + g.Post("/account/contributions/{name}/actions/{action}", interactiveOnly(requireAjax(s.accountContributionAction))) g.Get("/navigation", s.navigation) // Plugin field types (pact.HasFormFieldTypes): the SPA loads their // scripts and styles once after sign-in. diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 03122e80..916b2457 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -773,6 +773,12 @@ func conformCases() []conformCase { } return e.send(t, http.MethodDelete, fmt.Sprintf("/account/tokens/%d", e.tokenID), nil, true) }, into[cabana.Envelope[cabana.AdminAPIToken]](), nil}, + {"GET /account/contributions", 200, "cabana.Envelope-array_cabana_AccountContributionView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/account/contributions", nil, true) + }, into[cabana.Envelope[[]cabana.AccountContributionView]](), nil}, + {"POST /account/contributions/{name}/actions/{action}", 200, "cabana.Envelope-cabana_AccountActionResultView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/account/contributions/conform_link/actions/open", nil, true) + }, into[cabana.Envelope[cabana.AccountActionResultView]](), nil}, {"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, "/auth/logout", nil, true) }, into[cabana.Envelope[cabana.AdminLogoutData]](), nil}, @@ -1167,6 +1173,21 @@ func (p conformPlugin) AdminControllers() []pact.AdminController { // creates parts under deferral, so cabana's boot requires conformPart here. func (conformPlugin) Models() []any { return []any{&conformGadget{}, &conformPart{}} } +// AccountContributions is the My account section the conformance cases of +// GET /account/contributions and its action route exercise. +func (conformPlugin) AccountContributions() []pact.AccountContribution { + return []pact.AccountContribution{{ + Name: "conform_link", Label: "Conformance link", Description: "A section the conformance suite contributes.", + Status: func(context.Context, uint) (string, error) { return "linked", nil }, + Actions: []pact.AccountAction{{ + Name: "open", Label: "Open", + Run: func(_ context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) { + return pact.AccountActionResult{Message: "Opened", URL: "https://conform.example.test/link"}, nil + }, + }}, + }} +} + func (conformPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}} } diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index 58b4957e..acef9637 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -94,6 +94,7 @@ func TestPhase10Coverage(t *testing.T) { "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", "DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}", "POST /account/avatar", + "POST /account/contributions/{name}/actions/{action}", "POST /account/tokens", "POST /auth/login", "POST /auth/logout", diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index aaae948d..95ee2e38 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -72,9 +72,9 @@ func TestPhase10CSRF(t *testing.T) { // delete, record action, link, unlink, file upload, file reorder, file // caption, file remove, relation child create, update and delete, pivot // update, child file upload, reorder, caption and remove, plugin field - // type upload, tree reorder save - if unsafe != 34 { - t.Fatalf("walked %d state-changing routes, want 34: %v", unsafe, router.order) + // type upload, tree reorder save, account contribution action + if unsafe != 35 { + t.Fatalf("walked %d state-changing routes, want 35: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/record_action_prompt_postgres_test.go b/modules/cabana/record_action_prompt_postgres_test.go new file mode 100644 index 00000000..6e9c259a --- /dev/null +++ b/modules/cabana/record_action_prompt_postgres_test.go @@ -0,0 +1,76 @@ +package cabana_test + +import ( + "context" + "net/http" + "sync" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// linkPlugin contributes a prompted record action to the Administrators +// screen and records the value each run receives. +type linkPlugin struct { + mu *sync.Mutex + values *[]string +} + +func (linkPlugin) ID() string { return "acme.links" } +func (linkPlugin) Requires() []string { return nil } +func (linkPlugin) Register(*backpack.App) error { return nil } +func (linkPlugin) Boot(*backpack.App) error { return nil } + +func (p linkPlugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{{ + Name: "acme_link", Label: "Link site account", + Prompt: &pact.AdminActionPrompt{Label: "Site login", MaxLength: 32}, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + p.mu.Lock() + defer p.mu.Unlock() + *p.values = append(*p.values, in.Value) + return pact.AdminRecordActionResult{Message: "Linked " + in.Value}, nil + }, + }}, + }} +} + +// TestRecordActionPromptRun (Postgres): the preview carries the prompt, a +// posted payload reaches Run trimmed as Value, and an empty value is 422 on +// payload without running the action. +func TestRecordActionPromptRun(t *testing.T) { + var values []string + env := newScreensEnv(t, linkPlugin{mu: &sync.Mutex{}, values: &values}) + editor := env.seedAdmin(t, adminSeed{Name: "prompt-editor", RolePermissions: manageUsers}) + token := env.login(t, editor.Login) + target := env.seedAdmin(t, adminSeed{Name: "prompt-target"}) + + shown := env.expect(t, http.StatusOK, http.MethodGet, userURL(target.ID), token, nil) + meta, _ := shown["meta"].(map[string]any) + actions, _ := meta["actions"].([]any) + var prompt map[string]any + for _, raw := range actions { + action, _ := raw.(map[string]any) + if action["name"] == "acme_link" { + prompt, _ = action["prompt"].(map[string]any) + } + } + if prompt == nil || prompt["label"] != "Site login" || prompt["max_length"] != float64(32) { + t.Fatalf("prompt = %v in %v", prompt, actions) + } + + link := userURL(target.ID, "actions", "acme_link") + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, link, token, map[string]any{"payload": " "}, "payload", "Enter a value to run this action.", false) + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, link, token, map[string]any{"payload": 7}, "payload", "The payload must be a string.", false) + if len(values) != 0 { + t.Fatalf("a refused value ran the action: %v", values) + } + result := dataOf(env.expect(t, http.StatusOK, http.MethodPost, link, token, map[string]any{"payload": " acme "})) + if result["message"] != "Linked acme" || len(values) != 1 || values[0] != "acme" { + t.Fatalf("result = %v values = %v", result, values) + } +} diff --git a/modules/cabana/record_action_prompt_smoke_test.go b/modules/cabana/record_action_prompt_smoke_test.go new file mode 100644 index 00000000..77f57845 --- /dev/null +++ b/modules/cabana/record_action_prompt_smoke_test.go @@ -0,0 +1,158 @@ +package cabana + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// promptedAction is a contributed record action on the Administrators +// screen that asks for one text value (phase 19.7 D-34, planner decision +// P-01). +func promptedAction(name string, prompt *pact.AdminActionPrompt, got *[]string) pact.AdminRecordAction { + return pact.AdminRecordAction{ + Name: name, Label: "acme::lang." + name, Prompt: prompt, + Run: func(_ context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + *got = append(*got, in.Value) + return pact.AdminRecordActionResult{Message: "linked"}, nil + }, + } +} + +// TestRecordActionPromptSmoke: a prompted contributed action is offered with +// its prompt (label, max_length), an action without one carries no prompt +// key, the route accepts the payload only for a prompted action and refuses +// an empty or non-string value with 422 on payload, and a prompt without a +// label fails boot. +func TestRecordActionPromptSmoke(t *testing.T) { + var got []string + extender := screensExtender{extensions: []pact.AdminActionExtension{{ + Controller: AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{ + promptedAction("acme_link", &pact.AdminActionPrompt{Label: "acme::lang.site_login", Placeholder: "login", Comment: "acme::lang.site_login_hint", MaxLength: 8}, &got), + promptedAction("acme_link_long", &pact.AdminActionPrompt{Label: "acme::lang.site_login"}, &got), + pingAction(), + }, + }}} + reg, err := compileWithScreens(t, []party.Plugin{extender}) + if err != nil { + t.Fatal(err) + } + cc, ok := reg.Get(AdministratorsControllerID) + if !ok { + t.Fatal("administrators controller missing") + } + + // The show payload: prompt for the prompted actions only. + super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true} + ctx := bouncer.WithUser(context.Background(), super) + offered, err := (CRUDService{}).offeredRecordActions(ctx, cc, &BackendUser{ID: 5, IsActivated: true}) + if err != nil { + t.Fatal(err) + } + byName := map[string]RecordAction{} + for _, action := range offered { + byName[action.Name] = action + } + link := byName["acme_link"] + if link.Prompt == nil || link.Prompt.Label != "acme::lang.site_login" || link.Prompt.MaxLength != 8 || link.Prompt.Placeholder != "login" || link.Prompt.Comment != "acme::lang.site_login_hint" { + t.Fatalf("acme_link = %+v", link) + } + if long := byName["acme_link_long"]; long.Prompt == nil || long.Prompt.MaxLength != 255 || long.Prompt.Placeholder != "" { + t.Fatalf("acme_link_long = %+v", long) + } + if ping := byName["acme_ping"]; ping.Prompt != nil { + t.Fatalf("acme_ping carries a prompt: %+v", ping) + } + raw, err := json.Marshal(byName["acme_ping"]) + if err != nil || strings.Contains(string(raw), "prompt") { + t.Fatalf("plain action JSON = %s err=%v", raw, err) + } + raw, _ = json.Marshal(link) + if !strings.Contains(string(raw), `"prompt":{"label":"acme::lang.site_login","placeholder":"login","comment":"acme::lang.site_login_hint","max_length":8}`) { + t.Fatalf("prompted action JSON = %s", raw) + } + + // The route: payload is decoded only for a prompted action. + svc := &service{app: backpack.New(nil), reg: reg} + post := func(action, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.SetPathValue("vendor", "summercms") + req.SetPathValue("plugin", "cabana") + req.SetPathValue("controller", "users") + req.SetPathValue("id", "5") + req.SetPathValue("action", action) + req = req.WithContext(bouncer.WithUser(req.Context(), super)) + rec := httptest.NewRecorder() + svc.recordAction(rec, req) + return rec + } + for name, body := range map[string]string{ + "empty": `{"payload":""}`, + "blank": `{"payload":" "}`, + "number": `{"payload":123}`, + "object": `{"payload":{"v":"x"}}`, + "absent": `{}`, + "too long": `{"payload":"123456789"}`, + "long runes": `{"payload":"ąęśćżźńół"}`, + } { + rec := post("acme_link", body) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"payload"`) { + t.Fatalf("%s: %d %s", name, rec.Code, rec.Body.String()) + } + } + // An action without a prompt still refuses a payload. + if rec := post("acme_ping", `{"payload":"x"}`); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "takes no record_id, values or payload") { + t.Fatalf("payload on a plain action = %d %s", rec.Code, rec.Body.String()) + } + // A valid value passes the route's checks; without a database the + // action itself cannot run here (the Postgres test covers the Run). + if rec := post("acme_link", `{"payload":" acme "}`); rec.Code != http.StatusInternalServerError { + t.Fatalf("valid value without a database = %d %s", rec.Code, rec.Body.String()) + } + if len(got) != 0 { + t.Fatalf("Run was called without a database: %v", got) + } + // Eight runes exactly pass the cap; the value is trimmed. + value, err := promptValue(context.Background(), nil, &pact.AdminActionPrompt{MaxLength: 8}, json.RawMessage(`" ąęśćżźńó "`)) + if err != nil || value != "ąęśćżźńó" { + t.Fatalf("promptValue = %q err=%v", value, err) + } + value, err = promptValue(context.Background(), nil, nil, json.RawMessage(`"ignored"`)) + if err != nil || value != "" { + t.Fatalf("promptValue without a prompt = %q err=%v", value, err) + } + + // Boot refuses a prompt without a label, contributed or owned. + bad := screensExtender{extensions: []pact.AdminActionExtension{{ + Controller: AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{promptedAction("acme_bad", &pact.AdminActionPrompt{}, &got)}, + }}} + if _, err := compileWithScreens(t, []party.Plugin{bad}); err == nil || !strings.Contains(err.Error(), "prompt needs a label") { + t.Fatalf("contributed prompt without a label: %v", err) + } + if _, err := compileRecordActions(promptOwner{}); err == nil || !strings.Contains(err.Error(), "prompt without a label") { + t.Fatalf("owned prompt without a label: %v", err) + } +} + +// promptOwner is a controller whose own record action has a prompt +// without a label. +type promptOwner struct{} + +func (promptOwner) ID() string { return "acme.demo.records" } +func (promptOwner) ModelName() string { return "Record" } +func (promptOwner) ConfigDir() string { return "controllers/records" } +func (promptOwner) RequiredPermissions() []string { return nil } +func (promptOwner) AdminRecordActions() []pact.AdminRecordAction { + var got []string + return []pact.AdminRecordAction{promptedAction("link", &pact.AdminActionPrompt{}, &got)} +} diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index d3d0560a..9e63d1a5 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -233,6 +233,20 @@ type RecordAction struct { // Danger marks an action whose confirm dialog takes the danger variant // (pact.AdminRecordAction.Danger); an ordinary action omits the key. Danger bool `json:"danger,omitempty"` + // Prompt is present only for an action that asks for one text value in + // its confirm dialog (pact.AdminRecordAction.Prompt); the admin posts the + // value as the body key payload, a JSON string. + Prompt *RecordActionPrompt `json:"prompt,omitempty"` +} + +// RecordActionPrompt is the text input of a prompted record action: the +// localized label, placeholder and comment, and the longest accepted value +// in runes. +type RecordActionPrompt struct { + Label string `json:"label"` + Placeholder string `json:"placeholder,omitempty"` + Comment string `json:"comment,omitempty"` + MaxLength int `json:"max_length"` } // FormRedirects are config_form.yaml defaultRedirect, create.* and update.*. diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index bceb892a..00a7553a 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -48,6 +48,8 @@ var phase09Routes = []adminRoute{ {key: "POST /account/tokens"}, {key: "GET /account/tokens/scopes"}, {key: "DELETE /account/tokens/{id}"}, + {key: "GET /account/contributions"}, + {key: "POST /account/contributions/{name}/actions/{action}"}, {key: "GET /navigation"}, {key: "GET /field-types"}, {key: "GET /settings"}, diff --git a/modules/cabana/subject_events.go b/modules/cabana/subject_events.go new file mode 100644 index 00000000..6c127f56 --- /dev/null +++ b/modules/cabana/subject_events.go @@ -0,0 +1,35 @@ +package cabana + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// backendSubjectGuard is the guard name backend subject events carry: the +// admin's own bouncer guard. +const backendSubjectGuard = "backend" + +// fireSubjectChanged fires one pact.MCPSubjectChanged of the given kind for +// every administrator id through the application's event bus, so the MCP +// endpoint re-lists a linked admin's tools or ends their sessions. A missing +// application or bus is a no-op and a listener's error is logged: the save +// that caused the event never fails because of it. +func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adminIDs ...uint) { + if app == nil || app.Events == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + for _, id := range adminIDs { + if id == 0 { + continue + } + event := pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: id, Kind: kind} + if err := app.Events.Fire(ctx, event); err != nil { + moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", kind, "admin_id", id, "error", err) + } + } +} diff --git a/modules/cabana/token_routes_test.go b/modules/cabana/token_routes_test.go index ba4ef2b1..15a846a6 100644 --- a/modules/cabana/token_routes_test.go +++ b/modules/cabana/token_routes_test.go @@ -43,6 +43,8 @@ var tokenRouteClasses = map[string]tokenRouteClass{ "POST /account/tokens": classInteractive, "GET /account/tokens/scopes": classInteractive, "DELETE /account/tokens/{id}": classInteractive, + "GET /account/contributions": classInteractive, + "POST /account/contributions/{name}/actions/{action}": classInteractive, "GET /navigation": classAllowsNav, "GET /field-types": classExplicitGate, diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 5028a776..6c07a94a 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -106,6 +106,8 @@ form: action_confirm: "Run “:action” on this record?" action_done: Action completed. action_stale: This action no longer applies to this record. The page has been refreshed. + action_prompt_required: Enter a value to run this action. + action_prompt_too_long: "The value may not be longer than :max characters." forbidden: You do not have permission to make this change. Nothing was saved. dependent_invalid: Choose one of the listed options. trigger_disabled_on: "Not editable while “:field” is on." diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index bf2df158..20b78e0c 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -116,6 +116,8 @@ form: action_confirm: "Wykonać „:action” na tym rekordzie?" action_done: Akcja została wykonana. action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona. + action_prompt_required: Wpisz wartość, aby uruchomić tę akcję. + action_prompt_too_long: "Wartość nie może być dłuższa niż :max znaków." forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane. dependent_invalid: Wybierz jedną z dostępnych opcji. trigger_disabled_on: "Nie można edytować, gdy „:field” jest włączone."