70 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12.1-user-plugin-admin-screens | 01 | execute | 1 |
|
true |
|
|
|
Phase Goal
ROADMAP Phase 12.1 goal (verbatim; the line is not written in As a / I want to / so that form and no story was invented for it): Backend admins manage frontend users, user groups and organisations in the admin SPA without SQL, so the PHP backend is not needed for user administration after cutover. The Users, User Groups and Organisations screens of the PHP user plugin are ported to golem15.user, driven by its fields.yaml/columns.yaml.
This plan's slice: after it, any plugin can offer permission-gated bulk actions and record actions, mark list rows with a state, and refuse a write with a 403 that the admin can read. The user screens (plans 03 and 04) are built from these parts.
Add four generic admin features to the framework (`modules/pact`, `modules/cabana`, the admin SPA): declared bulk actions (D-09), record actions (D-10), row state (D-12) and `cabana.ForbiddenError` mapped to 403 (D-27, the error half of G4). Each lands end to end: Go contract, route, per-principal schema, OpenAPI document, generated TS types, SPA component, phrase keys, neutral fixture test, module READMEs, docs pages and the rebuilt `dist/`.Purpose: success criteria 1 and 3 depend on these features; they must exist and be tagged (plan 02) before the plugin screens are written.
Output: the contracts and routes listed under "Artifacts this phase produces", the acme.roster fixture, smoke tests, updated READMEs and docs.
Repo: summercms.go only. Every commit keeps go vet ./... and go test ./... green and carries its own README, docs, OpenAPI, TS types and modules/boardwalk/dist changes, so scripts/check-admin-openapi.sh --check and scripts/check-admin-dist.sh stay clean at every commit. Code and planning docs go in separate commits. Never add co-author tags. Fixtures, READMEs and docs use neutral names (acme, blog) and never name a consuming application.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share. The names below are fixed for plans 02 to 05.)
- pact types:
AdminBulkAction{Name, Label, Confirm string; Permissions []string; Run},AdminBulkActionInput{Records []any},AdminBulkActionResult{Message string; Affected int},HasAdminBulkActions{AdminBulkActions() []AdminBulkAction};AdminRecordAction{Name, Label, Confirm string; Permissions []string; Applies; Run},AdminRecordActionInput{RecordID uint64; Record any},AdminRecordActionResult{Message string},HasAdminRecordActions{AdminRecordActions() []AdminRecordAction};RowState(string) with constantsRowStateDeleted("deleted"),RowStateNegative("negative"),RowStateDisabled("disabled");ListRowStates{ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error)}. - cabana types and functions:
ForbiddenError{Message string; Details map[string]any},BulkActionResult{Message, Affected},RecordAction{Name, Label, Confirm},CRUDService.BulkAction,CRUDService.RecordAction; fieldsCompiledController.BulkActions,CompiledController.RecordActions,BulkAction.Confirm,RecordMeta.Actions(jsonactions, omitempty),ListMeta.RowStates(jsonrow_states, omitempty),ListMessages.RowStateDeleted,.RowStateNegative,.RowStateDisabled. - YAML keys:
config_list.yamlbulkActions:(list of names) andmessages.rowStateDeleted,messages.rowStateNegative,messages.rowStateDisabled;config_form.yamlrecordActions:(list of names). - Routes:
POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/bulk/{action}(bodyAdminIDsRequest, resultEnvelope[BulkActionResult]);POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/actions/{action}(body{}, resultEnvelope[AdminActionResult]with an emptyfill). - OpenAPI stubs
AdminBulkAction,AdminRecordActioninmodules/cabana/admin_openapi.go; TS aliasesBulkAction,BulkActionResult,RecordActioninadmin/src/api/types.ts. - SPA components:
BulkActionsMenu.vue,RowStateBadges.vue,RecordActions.vue;ListToolbar.vuepropsbulkActions,bulkBusyand eventbulk;DataTable.vuepropsrowStates,stateLabels;ListView.vueonBulkAction;FormErrorBanner.vuepropforbidden;FormView.vueforbidden-save handling. - Phrase keys (en, pl):
backend::lang.list.{bulk_actions, bulk_confirm, bulk_done, bulk_stale, action_forbidden},backend::lang.form.{action_confirm, action_done, action_stale, forbidden},backend::lang.messages.list.{row_state_deleted, row_state_negative, row_state_disabled}. - Fixture:
modules/cabana/testdata/roster/(pluginacme.roster, controlleracme.roster.people),modules/cabana/phase121_fixture_test.go,modules/cabana/phase121_actions_test.go,modules/cabana/example_actions_test.go; SPA fixturesadmin/tests/fixtures/roster.*.json;admin/tests/smoke/actions.smoke.test.ts.
Planner decisions recorded for this plan
- Action namespaces. Bulk actions and record actions each have their own name space next to the existing toolbar and widget actions: a name is unique within its kind, and
createanddeletestay reserved in every kind. This departs from the RESEARCH note "one shared namespace" because D-14 needsactivateandunbanboth as a bulk action and as a record action; the routes differ per kind, so the names cannot be confused. - Row state transport. States travel in the list response's
meta.row_states(a typed field ofListMeta), keyed by the row id as a decimal string, not inside the untyped row map: a column key can then never collide with it. - Offered record actions. Only the show route (
GET .../{id}) fillsmeta.actions; create and update responses omit it (the SPA reads the record again when it opens the preview). - 403 text. A
ForbiddenErrorwith an emptyMessageis written with an emptymessage; the SPA then uses its own fallback key. The framework's generic permission denials keep their existing constant text. - Record actions in the SPA.
RecordActions.vueis built and tested here and mounted in the preview footer by plan 02 (UI-SPEC S2: record actions render on the preview screen only). The boot rule "recordActions needs a preview" is added by plan 02 together with thepreview:key. - Spec-less probe fallback: skipped. The phase has no requirement IDs and no SPEC.md, so no edge or prohibition probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" and the UI-SPEC "UI Considerations" rows, which are lifted into
must_haves. - Assumption-delta and API-coverage detectors: both report no signal for this phase (no external API is integrated; no singular-to-plural identity change). The schema push gate does not apply (GORM with gormigrate, none of the listed ORMs).
(1) pact (modules/pact/capabilities.go, directly after HasAdminActions): add AdminBulkAction with fields Name, Label, Confirm (strings), Permissions ([]string) and Run of type func(ctx context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error), the Run field tagged json "-"; AdminBulkActionInput with the single field Records []any (pointers to the controller's model, loaded and row-locked through ListExtendQuery, ordered by primary key; never ids); AdminBulkActionResult with Message string and Affected int; interface HasAdminBulkActions with method AdminBulkActions() []AdminBulkAction. Doc comments follow AdminAction's style and state: the framework owns the route, the CSRF check, authentication, id scoping and the transaction; Run owns only business logic and reads the write transaction with cabana.TxFromContext; Name is an identifier unique among the controller's bulk actions, create and delete are reserved, a record action may reuse a bulk action's name; Label and Confirm are phrase keys or text; an empty Confirm means the framework's default confirm text; Permissions are checked on top of RequiredPermissions.
(2) Compile. contracts.go: CompiledController gains BulkActions map[string]pact.AdminBulkAction. extension.go: a compileBulkActions(ctl) with the four boot errors compileActions has (name not an identifier, reserved built-in name, duplicate, no Run), called from compileExtension and stored on the controller. registry.go compileContributions: validate each bulk action's Permissions with validatePermissions, owner text "bulk action ID.NAME". list_schema.go: listDocument gains a field for yaml key bulkActions, decoded like toolbarButtons (a sequence of names, duplicates refused, a scalar refused with the message "bulkActions must be a list of bulk action names the controller registers"); in compileList, a non-empty list without showCheckboxes: true is the boot error "bulkActions needs showCheckboxes: true", a name the controller does not register through pact.HasAdminBulkActions is "bulkActions: unsupported action NAME (want a bulk action the controller registers)", an empty Label is "bulkActions: action NAME needs a label"; all wrapped with bootErr. Declared entries are appended to the existing bulk slice after the built-in delete entry, in declared order, each carrying Name, Label and Confirm. schema_types.go: BulkAction gains Confirm (json key confirm, omitempty) and its doc comment says the entry is per-principal for declared actions; localizeBulkActions translates Confirm as well as Label.
(3) Service and route. crud.go: add BulkActionResult (Message json message, Affected json affected) and CRUDService.BulkAction(ctx, cc, action, in BulkDeleteInput), copying BulkDelete's shape: normalizeIDs, lagoon.Transaction, withTx, newWritableModel, lockScoped; zero rows returns Affected 0 without calling Run; a row count different from the id count returns partialSelection; otherwise exactly one call of action.Run with the loaded rows; a Run error goes through lifecycleFailure; Message is resolved with the service's translator in the request locale (translateKey). actions.go: add bulkActionOf(cc, name) (true only when the name is in the list's declared bulk actions, is not the built-in delete, and is registered in cc.BulkActions) and the handler bulkAction: protect, then bulkActionOf (404 not_found), then the action's own permissions (turn allowAction into a helper that takes the permission list so every action kind shares the 403 and the auth log line), then decodeCappedBulk, then the service call, writeCRUDError on error, WriteData 200 on success. After a successful run write one slog.Info line "cabana: admin bulk action" with the controller id, action name, admin id and affected count and no record contents. http.go mount: register POST /{vendor}/{plugin}/{controller}/bulk/{action} wrapped in requireAjax with constrainController and the same identifier constraint on action as the toolbar route. listSchema handler: after the toolbar filter, rebuild view.BulkActions into a new slice that keeps the built-in delete entry and each declared entry whose registered Permissions the principal passes (the cached schema is never mutated).
(4) Contract. admin_openapi.go: stub function AdminBulkAction modelled on AdminBulkDelete (summary "Run a declared bulk action", a description stating that ids are resolved through the list scope in one transaction and that a partial selection is 409, path parameter action, body AdminIDsRequest, success Envelope[BulkActionResult], failures 401, 403, 404, 409, 422, router /{vendor}/{plugin}/{controller}/bulk/{action} post). security_coverage_test.go: add the row for "POST /{vendor}/{plugin}/{controller}/bulk/{action}" to phase09Routes. Run scripts/check-admin-openapi.sh, then add aliases BulkAction and BulkActionResult to admin/src/api/types.ts.
(5) Fixture (neutral, reused by every later task of plans 01, 02 and 05). Tree modules/cabana/testdata/roster: controllers/people/config_list.yaml (modelClass matching the controller's ModelName, list pointing at models/person/columns.yaml, showCheckboxes: true, toolbar.buttons [create, delete], bulkActions [activate, archive], a messages block), controllers/people/config_form.yaml, models/person/columns.yaml (name, email), models/person/fields.yaml (name, email as type text), lang/en/lang.yaml and lang/pl/lang.yaml with the acme.roster::lang.* keys the YAML and actions name. modules/cabana/phase121_fixture_test.go: plugin rosterPlugin (ID acme.roster; permissions acme.roster.access and acme.roster.manage; AdminFS over the fixture directory; LangFS as actPlugin does), controller rosterController (ID acme.roster.people, ConfigDir controllers/people, RequiredPermissions acme.roster.access, NewRecord returning a rosterPerson with columns id, tenant, name, email, active, banned and a gorm.DeletedAt deleted_at, table roster_people; ListExtendQuery and FormExtendQuery scoping to tenant acme), a spy recording what each Run receives, bulk actions activate (permission acme.roster.manage; sets active true on rows that are not active through cabana.TxFromContext and returns the changed count as Affected) and archive (permission acme.roster.access), and newRosterEnv modelled on newActEnv with the four auth modes of actEnv.call.
(6) Smoke test modules/cabana/phase121_actions_test.go: TestBulkActionTracer (the list schema offers activate and archive to the full admin and omits activate for the limited admin; POST bulk/activate with two in-scope ids answers 200 with affected 2 and both rows are active; the spy saw two records and no id list; an id of another tenant mixed in answers 409 and nothing changed; an undeclared name answers 404; the limited admin gets 403; a cookie-only request without the Ajax header gets 403) and TestListSchemaBulkActionsBoot (an unregistered name, a duplicate, and bulkActions without showCheckboxes each fail CompileList with the messages above). modules/cabana/example_actions_test.go: a compiled example controller registering one bulk action, for the docs src= fence.
(7) SPA per UI-SPEC S1. New admin/src/components/list/BulkActionsMenu.vue: Reka DropdownMenuRoot (modal false), a trigger rendered as Button variant outline size md with the label key backend::lang.list.bulk_actions, a trailing ChevronDown 16px and data-action="bulk-actions"; content with the UserMenu surface classes at width 240 and maximum width 320, align end, side offset 8; one DropdownMenuItem per action in declared order with data-bulk-action set to the name, minimum height 40, text that wraps and is never truncated, no icons, groups or separators; props actions and disabled; event select with the action name. ListToolbar.vue: props bulkActions (default empty) and bulkBusy, event bulk; the menu sits directly after the selection pill and before the toolbar buttons, is rendered only when at least one action exists, and its trigger is disabled while nothing is selected or an action runs. ListView.vue: bulkActions computed from schema.bulkActions without the built-in delete; onBulkAction(name) copying onDelete: normalise ids, then confirm.ask with the action's confirm text (or backend::lang.list.bulk_confirm with :action and :count), the action label as the confirm label, danger false, and the POST passed as the second argument of ask so the dialog stays open and busy; on 200 show the server message or backend::lang.list.bulk_done with the affected count, clear the selection, reload the list and bump partialReload; on 409 show backend::lang.list.bulk_stale, clear the selection and reload; on 403 show the server message or backend::lang.list.action_forbidden and keep the selection; on any other status show the server message or backend::lang.extension.action_failed, keep the selection and reload nothing. Server text is rendered through text interpolation only. Every string goes through t, tc or message.
(8) Phrase keys in modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with the UI-SPEC copy: list.bulk_actions, list.bulk_confirm, list.bulk_done (CLDR map: en one and other; pl one, few, many, other), list.bulk_stale, list.action_forbidden.
(9) SPA smoke test admin/tests/smoke/actions.smoke.test.ts with fixtures admin/tests/fixtures/roster.list-schema.json and roster.list.json: mount ListView; with nothing selected the trigger is disabled; select two rows, open the menu, choose activate, confirm; assert the POST path ends with /bulk/activate, the body ids are the two selected ids, the success toast shows, and the selection is cleared; a schema whose bulkActions holds only delete renders no menu; a label containing markup characters is rendered as text.
(10) Docs and READMEs in the same commit: modules/pact/README.md API reference (the four new identifiers); modules/cabana/README.md (Features, the Admin API routes table row for the bulk route, API reference: BulkActionResult, CRUDService.BulkAction); docs/backend/admin-controllers.md new section "Bulk actions" after "Toolbar actions" with a Go fence that is a src= reference to modules/cabana/example_actions_test.go and a YAML fence that is a src= reference to the roster config_list.yaml; docs/backend/lists-and-filters.md (the bulkActions key, the showCheckboxes rule, the 409 rule); docs/backend/admin-spa.md (the bulk menu in one paragraph). Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the source.
go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestBulkAction|TestListSchemaBulkActions|TestBulkDelete|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance|TestPhase101)' -count=1 -v && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check
<fails_when>Any command exits non-zero; the cabana run lacks the line "--- PASS: TestBulkActionTracer" or prints "no tests to run"; TestPhase10SPAKeysResolve prints a line ending in "does not resolve"; vitest prints "FAIL" or "No test files found"; check-admin-openapi.sh prints "committed admin OpenAPI output is stale"; check-admin-dist.sh prints "modules/boardwalk/dist is stale"; docs:build prints anything other than "no problems found".</fails_when>
<acceptance_criteria>
- go doc ./modules/pact AdminBulkAction and go doc ./modules/pact HasAdminBulkActions each print a declaration.
- grep -c 'requireAjax(s.bulkAction)' modules/cabana/http.go prints 1.
- grep -c '/{vendor}/{plugin}/{controller}/bulk/{action}' modules/cabana/security_coverage_test.go prints 1 and grep -c 'bulk/{action}' admin/openapi/admin.json prints at least 1.
- go test ./modules/cabana -run '^TestBulkActionTracer$' -count=1 -v prints "--- PASS: TestBulkActionTracer"; the test asserts that the spy received loaded records, that a mixed in-scope and out-of-scope selection answers 409 with no row changed, and that an undeclared name answers 404.
- go test ./modules/cabana -run '^TestBulkDelete' -count=1 passes unchanged (bulk delete keeps working as before).
- npm --prefix admin test -- tests/smoke/actions reports the smoke file passed.
- grep -c 'bulk_stale' modules/phrasebook/backend/lang/pl/lang.yaml prints 1.
- git diff --stat PLAN_START_SHA -- go.mod go.sum admin/package.json admin/package-lock.json prints nothing after the task's commit, where PLAN_START_SHA is the commit sha the executor recorded with git rev-parse HEAD before starting Task 1.
</acceptance_criteria>
A plugin can declare a bulk action in Go and YAML, and an admin can run it on selected rows from the embedded SPA; ids outside the list scope never reach plugin code.
(1) pact: AdminRecordAction with Name, Label, Confirm, Permissions, Applies of type func(ctx context.Context, record any) (bool, error) and Run of type func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error), both function fields tagged json "-"; AdminRecordActionInput with RecordID uint64 and Record any (the record loaded through FormExtendQuery with a row lock); AdminRecordActionResult with Message string; interface HasAdminRecordActions with AdminRecordActions() []AdminRecordAction. Doc comments state: a nil Applies means the action always applies; Applies must be a pure read (it also runs on the show route); names are unique among the controller's record actions; create and delete are reserved.
(2) Compile. CompiledController gains RecordActions map[string]pact.AdminRecordAction; compileRecordActions(ctl) in extension.go with the same four boot errors; compileContributions validates their Permissions (owner text "record action ID.NAME"). form_schema.go: formConfigDocument gains yaml key recordActions decoded as the bulkActions list is (sequence of names, duplicates and scalars refused); FormSchema keeps the declared names in an unexported ordered field; compileExtension (which sees the controller) refuses a declared name the controller does not register ("recordActions: unsupported action NAME (want a record action the controller registers)") and an empty Label ("recordActions: action NAME needs a label") with bootErr naming config_form.yaml.
(3) Offered actions. schema_types.go: RecordAction with Name, Label and Confirm (json name, label, confirm with omitempty on confirm). relation_field.go: RecordMeta gains Actions []RecordAction with json key actions and omitempty, so responses without offered actions keep their bytes. crud.go ShowRecord: inside its read transaction, with the transaction placed on the context through withTx, build the offered list in declared order from the form's declared record actions that pass Allows for the principal on the context (bouncer.User) and whose Applies is nil or reports true; Label and Confirm are localized with the service translator; an Applies error is a lifecycle failure. Create and update responses leave Actions empty.
(4) Run. crud.go: CRUDService.RecordAction(ctx, cc, id, action): lagoon.Transaction, withTx, newWritableModel, coercePK, loadRecord (missing and out-of-scope ids are the same recordNotFound), then Applies (false returns a new unexported conflict error that writeCRUDError maps to 409 "conflict", like partialSelection), then Run with RecordID and Record; the result Message is localized. actions.go: recordActionOf(cc, name) (declared in the form and registered) and handler recordAction: protect, recordActionOf (404), the shared own-permission check (403), pathID, decodeActionRequest with the rule that record_id and values must be absent (422 "A record action takes no record_id or values."), the service call, writeCRUDError on error, WriteData 200 with AdminActionResult whose Fill is an empty object. Log one slog.Info line "cabana: admin record action" with controller id, action name, admin id and record id. http.go mount: POST /{vendor}/{plugin}/{controller}/{id}/actions/{action} wrapped in requireAjax with constrainController and the action identifier constraint.
(5) Contract. admin_openapi.go: stub AdminRecordAction modelled on AdminToolbarAction (path parameters id and action, body AdminActionRequest described as an empty object, success Envelope[AdminActionResult], failures 401, 403, 404, 409, 422, router /{vendor}/{plugin}/{controller}/{id}/actions/{action} post); the AdminShow description gains one sentence on meta.actions. Add the route row to phase09Routes. Regenerate with scripts/check-admin-openapi.sh; add alias RecordAction to admin/src/api/types.ts.
(6) Fixture and smoke test. The roster controller registers record actions activate (permission acme.roster.manage; Applies when active is false; sets active true) and reinstate (Applies when banned is true; sets banned false); config_form.yaml declares recordActions [activate, reinstate]. TestRecordActionSmoke: show of an inactive person lists activate and not reinstate for the full admin, and no action for the limited admin; POST {id}/actions/activate answers 200 and the row is active; a second POST answers 409; a person of another tenant answers 404; the limited admin gets 403; a body with record_id answers 422; an undeclared name answers 404. Extend example_actions_test.go with one record action.
(7) SPA per UI-SPEC S2. New admin/src/components/form/RecordActions.vue: props source (controller params), recordId, actions (RecordAction list from the record response) and disabled; renders one Button variant outline size md per action in the given order with data-record-action set to the name and whitespace-nowrap; a click asks through useConfirm with the action's confirm text or backend::lang.form.action_confirm with :action, the action label as confirm label, danger false, and the POST as the run argument; emits busy (true while a request runs), done with the toast text (server message or backend::lang.form.action_done), stale on 409 after showing backend::lang.form.action_stale, gone on 404, and on 403 or any other failure shows a danger toast with the server message or backend::lang.list.action_forbidden (403) or backend::lang.extension.action_failed. While one action runs every button it renders is disabled. It renders nothing when actions is empty. Phrase keys form.action_confirm, form.action_done, form.action_stale in en and pl with the UI-SPEC copy. Extend admin/tests/smoke/actions.smoke.test.ts with fixture roster.record.json: mounting RecordActions with two offered actions renders two buttons in order; confirming one posts to .../{id}/actions/{name} with body {} and emits done; a 409 emits stale.
(8) Docs and READMEs in the same commit: modules/pact/README.md; modules/cabana/README.md (route table row, API reference: RecordAction, CRUDService.RecordAction); docs/backend/admin-controllers.md section "Record actions" (Applies, permissions, 404 and 409 outcomes, a src= Go fence); docs/backend/forms.md (the recordActions key of config_form.yaml and meta.actions on the show response). Rebuild and commit modules/boardwalk/dist.
go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestRecordAction|TestBulkAction|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance|TestPhase101)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check
<fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestRecordActionSmoke" or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; check-admin-openapi.sh prints "is stale"; check-admin-dist.sh prints "is stale"; docs:build prints anything other than "no problems found".</fails_when>
<acceptance_criteria>
- go doc ./modules/pact AdminRecordAction prints a struct with the fields Name, Label, Confirm, Permissions, Applies and Run.
- grep -c 'requireAjax(s.recordAction)' modules/cabana/http.go prints 1 and grep -c '{id}/actions/{action}' modules/cabana/security_coverage_test.go prints 1.
- go test ./modules/cabana -run '^TestRecordActionSmoke$' -count=1 -v prints "--- PASS: TestRecordActionSmoke"; the test asserts 404 for an out-of-scope record, 409 when Applies is false and 403 for the limited admin.
- The JSON body of a show response for a controller without record actions has no actions key in meta (asserted in TestRecordActionSmoke against a second fixture controller or the existing acme.demo fixture).
- grep -c 'action_stale' modules/phrasebook/backend/lang/en/lang.yaml prints 1.
- test -f admin/src/components/form/RecordActions.vue succeeds and npm --prefix admin test -- tests/smoke/actions reports the smoke file passed.
</acceptance_criteria>
A plugin can declare record actions with their own permissions and an applicability rule; the server offers and runs them only for records in the form scope, and the SPA component that renders them is ready to be mounted on the preview screen.
(1) pact: type RowState (string) with constants RowStateDeleted, RowStateNegative and RowStateDisabled holding the values deleted, negative and disabled; interface ListRowStates with method ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error). Doc comment: the framework calls it once per list page with the page's records in page order and the list's database handle (a list does not run in a transaction, so cabana.TxFromContext is not available); the result is index-aligned with records; a row may carry several states; values outside the three constants are dropped.
(2) cabana. query.go: ListMeta gains RowStates map[string][]string with json key row_states and omitempty. ExecuteList: after the page is loaded, when the controller implements pact.ListRowStates call it exactly once with the request context, the handle used for the list and the page's record pointers; a returned error or a result whose length differs from the record count fails the list (the handler answers the generic 500); for each row keep only the three known values, de-duplicated, in the fixed order deleted, negative, disabled; an unknown value is dropped and reported with slog.Warn naming the controller and the value; rows with no state are left out of the map; the map key is the row's primary key as a decimal string. http.go list handler: add "row_states" to the meta map only when the map is non-empty. messages.go: listMessageKeys, ListMessages and listMessageDefaults gain RowStateDeleted, RowStateNegative and RowStateDisabled (yaml keys rowStateDeleted, rowStateNegative, rowStateDisabled; JSON keys the same; defaults backend::lang.messages.list.row_state_deleted, row_state_negative, row_state_disabled). The AdminList swag description gains one sentence on meta.row_states. Regenerate the OpenAPI outputs.
(3) Phrase keys in en and pl: messages.list.row_state_deleted "Deleted" / "Usunięty", row_state_negative "Blocked" / "Zablokowany", row_state_disabled "Inactive" / "Nieaktywny".
(4) Fixture and smoke test: the roster controller implements ListRowStates (deleted when deleted_at is set, negative when banned, disabled when not active) and both its ListExtendQuery and its FormExtendQuery include soft-deleted rows (an unscoped query narrowed to tenant acme), as a Winter controller with withTrashed does; TestRowStateSmoke asserts the list meta carries the expected states for three seeded rows, that a row with two states lists them in the fixed order, that the hook ran exactly once for the page, and that a fixture controller returning an unknown value sends no such value; a controller without the hook has no row_states key. TestSoftDeletedRecordSmoke asserts that a soft-deleted person reached through that scope can be shown, updated (200, the changed column is stored and deleted_at is kept), targeted by a bulk action and a record action, and deleted through the form delete route and bulk delete, with the controller's FormAfterDelete removing the row for good inside the same transaction. If the current write path refuses any of these, change crud.go so the row write and the delete of a record use the scope its load used (a record loaded through the controller's scope is always writable and deletable through it); records of controllers that do not include soft-deleted rows must behave exactly as before.
(5) SPA per UI-SPEC S4. New admin/src/components/list/RowStateBadges.vue: props states and labels; renders one badge per known state in the fixed order with data-row-state set to the state and the classes of the S4 badge geometry and per-state colours (deleted: border-border-strong and text-muted; negative: bg-danger-soft and text-danger; disabled: bg-subtle and text-muted); badges are never struck through; an unknown state renders nothing. DataTable.vue: props rowStates (map of row id to state list, default empty) and stateLabels; each row element gets data-row-states with the known states joined by a space (the attribute is absent without states); the first cell becomes a flex row with gap 2 holding the link or text (min-w-0 truncate) and then the badges; text styles per the S4 table (deleted: first-cell text line-through and every cell text-muted; negative: first-cell text text-danger; disabled: every cell text-muted, first cell keeps weight 600), combining when states combine; the row background, selection, sorting, links and row click are unchanged. ListView.vue passes meta.row_states and the three localized labels from schema.messages. Extend the smoke test and the roster list fixtures: a row with deleted and negative renders two badges in order and the data-row-states attribute; an unknown state from the server renders no badge and no class.
(6) Docs and READMEs in the same commit: modules/pact/README.md (RowState, ListRowStates); modules/cabana/README.md (Features); docs/backend/lists-and-filters.md new section "Row state" (the hook, the fixed set, the three messages keys, a src= Go fence from example_actions_test.go extended with a ListRowStates example); docs/backend/admin-spa.md (badges in one sentence). Rebuild and commit modules/boardwalk/dist.
go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestRowState|TestSoftDeletedRecord|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./modules/cabana/... -count=1 && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check
<fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestRowStateSmoke" or "--- PASS: TestSoftDeletedRecordSmoke", or prints "no tests to run"; TestPhase10SPAKeysResolve prints "does not resolve"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when>
<acceptance_criteria>
- go doc ./modules/pact ListRowStates prints the interface and go doc ./modules/pact RowStateNegative prints the constant.
- go test ./modules/cabana -run '^TestRowStateSmoke$' -count=1 -v prints "--- PASS: TestRowStateSmoke"; the test asserts one hook call per page and the fixed state order.
- go test ./modules/cabana -run '^TestSoftDeletedRecordSmoke$' -count=1 -v prints "--- PASS: TestSoftDeletedRecordSmoke"; the test asserts that a soft-deleted record in the controller's scope can be shown, updated, acted on and permanently deleted through the admin API.
- grep -c 'row_states' admin/src/api/schema.d.ts prints at least 1.
- grep -c 'row_state_negative' modules/phrasebook/backend/lang/pl/lang.yaml prints 1.
- npm --prefix admin test -- tests/smoke/actions reports the smoke file passed, including the case where an unknown state renders no badge.
</acceptance_criteria>
A controller can mark list rows with states from a fixed set in one call per page, and the admin sees each state as a text badge with the token styling of the design system.
(1) crud.go: exported ForbiddenError with fields Message string (a phrase key or text; may be empty) and Details map[string]any (field name to a list of messages, each a phrase key or text), and a pointer-receiver Error method returning "forbidden", in the form of ValidationError. Classification, in every place an error is classified: writeCRUDError gains a branch that answers 403 with code forbidden, the error's Message and its Details through WriteErrorDetails; lifecycleFailure returns a *ForbiddenError unchanged (otherwise a hook's error would become the opaque lifecycle error); runAction in actions.go answers it as writeCRUDError does instead of the generic 500. The relation manager paths (link, unlink and the child create, update and delete hooks in relation.go and relation_child.go) let a *ForbiddenError through to the same 403 wherever they already let a *ValidationError through. Localization: the service that ran the write (CRUDService, RelationService) resolves Message and every Details string with its translator in the request locale before the error leaves it; runAction does the same with the handler's translator; an empty Message stays empty. A ForbiddenError returned inside a transaction rolls the whole write back (no partial save). Every other non-validation error keeps the opaque 500 and its log line; error text never reaches the client.
(2) Swag: add the 403 description sentence to AdminCreate, AdminUpdate, AdminDelete, AdminBulkDelete, AdminBulkAction and AdminRecordAction ("also returned when controller code refuses the write; details may name fields"). Regenerate the OpenAPI outputs (no schema type changes are expected; the check must stay clean).
(3) Fixture and smoke tests in phase121_actions_test.go: the roster controller's FormBeforeUpdate returns a ForbiddenError with a phrase-key Message and Details on name when the submitted name equals a sentinel value, its bulk action archive and record action reinstate return one for a sentinel row. TestForbiddenSmoke: the update answers 403 with code forbidden, the localized message and details.name, and the row is unchanged; the bulk action answers 403 and no selected row changed; the record action answers 403 and the row is unchanged; a hook returning a plain error still answers 500 with the generic body and no error text.
(4) SPA per UI-SPEC S6 "Forbidden save". FormErrorBanner.vue gains the optional prop forbidden (string or null): when set it renders the banner geometry with data-forbidden-banner, role alert, the CircleAlert icon and the text; the 422 banner is unchanged. FormView.vue: on a 403 from create or update keep every entered value and the dirty state, set the forbidden text to the server error.message when it is non-empty, else backend::lang.form.forbidden, apply error.details to the fields through fieldErrors and focus the first such field (switching tab when needed) as showErrors does, and show no toast; clear the forbidden text at the start of the next save attempt. A 403 on the form's delete shows a danger toast with the server message or backend::lang.list.action_forbidden. Phrase keys form.forbidden in en and pl with the UI-SPEC copy. Extend the smoke test: a 403 on save renders the banner with the server message, keeps the typed value and marks the detailed field; a 403 with an empty message renders the form.forbidden text; a second save attempt removes the banner before the request settles.
(5) Docs and READMEs in the same commit: modules/cabana/README.md API reference (ForbiddenError); docs/backend/admin-controllers.md section "Refusing a write" (ValidationError is 422, ForbiddenError is 403 and rolls back, anything else is the opaque 500); docs/backend/users-and-permissions.md (action permissions on bulk and record actions, and the difference between a permission denial and a ForbiddenError). Rebuild and commit modules/boardwalk/dist.
go vet ./... && go test ./modules/cabana -run '^(TestForbidden|TestBulkAction|TestRecordAction|TestRowState|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./... -count=1 && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check
<fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestForbiddenSmoke" or prints "no tests to run"; the full go test run prints a line starting with "FAIL"; vitest prints "FAIL" or "No test files found"; either generated-output check prints "is stale"; docs:build prints anything other than "no problems found".</fails_when>
<acceptance_criteria>
- go doc ./modules/cabana ForbiddenError prints a struct with the fields Message and Details.
- go test ./modules/cabana -run '^TestForbiddenSmoke$' -count=1 -v prints "--- PASS: TestForbiddenSmoke"; the test asserts status 403, code forbidden, the unchanged row after each refused write, and a generic 500 body for a plain hook error.
- grep -c 'data-forbidden-banner' admin/src/components/form/FormErrorBanner.vue prints 1.
- grep -c 'forbidden:' modules/phrasebook/backend/lang/pl/lang.yaml prints at least 1.
- go vet ./... && go test ./... -count=1 exits 0 in summercms.go at the task's commit.
</acceptance_criteria>
Plugin hooks and actions can refuse a write with a 403 the admin can read; the refused write changes nothing, and the SPA shows a persistent banner on a refused save and a toast on a refused action.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Admin browser → admin API (new POST routes) | Untrusted ids, action names and bodies cross here; cookie auth needs the CSRF header |
| cabana → plugin action code | The framework hands over only records it loaded through the controller's scope |
| Plugin code → admin browser | Action labels, confirm texts, messages, error text and row states are rendered in the SPA |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12.1-01 | Elevation of Privilege | bulk action ids (IDOR) | high | mitigate | CRUDService.BulkAction resolves ids with lockScoped (ListExtendQuery) before Run; Run receives records, never ids; a partial match is 409 and rolls back (Task 1, TestBulkActionTracer). |
| T-12.1-02 | Elevation of Privilege | running an action without its permission, or an undeclared action | high | mitigate | bulkActionOf / recordActionOf (404 unless declared in YAML and registered), the shared own-permission check (403 plus auth log), per-principal filtering of the list schema and meta.actions, boot validation of action permissions (Tasks 1, 2). |
| T-12.1-03 | Tampering | CSRF on the two new POST routes | high | mitigate | Both routes are mounted with requireAjax under the backend guard; the cookie-only auth mode is asserted 403 (Tasks 1, 2). |
| T-12.1-04 | Elevation of Privilege | record action on a record outside the form scope or in the wrong state | high | mitigate | loadRecord with FormExtendQuery and a row lock (one 404 for missing and out-of-scope), Applies re-checked inside the transaction (409) (Task 2). |
| T-12.1-05 | Tampering | half-applied bulk mutation | medium | mitigate | One lagoon.Transaction per request; any error, including ForbiddenError, rolls every row back (Tasks 1, 4). |
| T-12.1-06 | Information Disclosure | internal error text in 403 or 500 bodies | medium | mitigate | Only ForbiddenError.Message and Details (plugin-authored phrase keys or text) are written; every other error keeps the opaque 500 and a server-side log (Task 4, TestForbiddenSmoke). |
| T-12.1-07 | Tampering | plugin-supplied row state or label used as markup or CSS class | low | mitigate | Row states are reduced to a fixed set on the server and again in the SPA; labels, confirms and messages are rendered through text interpolation only (Tasks 1, 3). |
| T-12.1-08 | Repudiation | bulk and record actions leave no trace | low | mitigate | One slog.Info line per run with controller, action, admin id and affected count or record id; no record contents (Tasks 1, 2). |
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed or bumped by this plan; the acceptance check on go.mod and admin/package.json proves it. Any need for a package stops the plan at a blocking human checkpoint. |
| </threat_model> |
<success_criteria>
- Bulk actions, record actions, row state and
ForbiddenErrorexist with the names in "Artifacts this phase produces" and behave as the D-09, D-10, D-12 and D-27 (G4 error half) truths state. - The SPA shows the bulk menu, the row-state badges and the forbidden banner as UI-SPEC S1, S4 and S6 describe;
RecordActions.vueimplements S2 and is ready for the preview footer. - READMEs, docs, the OpenAPI document, the TS types and
dist/match the code; no consuming application is named; no dependency changed. </success_criteria>