docs(12.1): create phase plan
This commit is contained in:
398
.planning/phases/12.1-user-plugin-admin-screens/12.1-01-PLAN.md
Normal file
398
.planning/phases/12.1-user-plugin-admin-screens/12.1-01-PLAN.md
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
phase: 12.1-user-plugin-admin-screens
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/capabilities_test.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/actions.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/messages.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/phase121_fixture_test.go
|
||||
- modules/cabana/phase121_actions_test.go
|
||||
- modules/cabana/example_actions_test.go
|
||||
- modules/cabana/testdata/roster
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/components/list/BulkActionsMenu.vue
|
||||
- admin/src/components/list/RowStateBadges.vue
|
||||
- admin/src/components/list/ListToolbar.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/src/components/form/RecordActions.vue
|
||||
- admin/src/components/form/FormErrorBanner.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/tests/smoke/actions.smoke.test.ts
|
||||
- admin/tests/fixtures/roster.list-schema.json
|
||||
- admin/tests/fixtures/roster.list.json
|
||||
- admin/tests/fixtures/roster.record.json
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/users-and-permissions.md
|
||||
- docs/backend/admin-spa.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-26, this is plan 01 of five and covers the framework actions only (declared bulk actions, record actions, row state and the 403 error type) in summercms.go; plans 02 to 05 follow in order and plan 02 tags v0.1.3."
|
||||
- "Per D-09, a controller registers named bulk actions through pact.HasAdminBulkActions, config_list.yaml offers them with `bulkActions:` (which needs showCheckboxes: true), and POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/bulk/{action} resolves the posted ids through the controller's ListExtendQuery scope with row locks inside one transaction before the action runs: zero matching rows is a no-op answer with affected 0, a partial match is 409 and rolls back, and the action receives loaded records, never ids."
|
||||
- "Per D-09, a bulkActions name the controller does not register, a duplicate name, a reserved name (create, delete), an action without Run or without a label, or bulkActions without showCheckboxes: true stops boot with an error naming plugin, controller and file; bulk delete behaves exactly as before."
|
||||
- "Per D-10, a controller registers record actions through pact.HasAdminRecordActions, config_form.yaml offers them with `recordActions:`, and POST .../{controller}/{id}/actions/{action} loads the record through FormExtendQuery with a row lock in one transaction, answers 404 for a missing or out-of-scope id, 409 when the action's Applies reports false, and otherwise runs the action; the show response lists in meta.actions only the declared actions the admin may run and that apply to the record."
|
||||
- "Bulk and record actions each check their own Permissions on top of the controller's RequiredPermissions (403 and an auth log line on denial), are left out of the list schema and of meta.actions for an admin who may not run them, use requireAjax, sit under the backend guard, and an undeclared or unregistered action name answers 404."
|
||||
- "Per D-12, a controller implementing pact.ListRowStates is called once per list page with that page's records and the list's database handle; the response carries meta.row_states keyed by row id with values from the fixed set deleted, negative, disabled in that order; a value outside the set is dropped and logged, never sent; a controller without the hook sends no row_states key."
|
||||
- "Per D-13 (framework side), a soft-deleted record that a controller's ListExtendQuery and FormExtendQuery include (Winter's withTrashed) can be shown, updated, targeted by bulk and record actions and permanently deleted by the controller's FormAfterDelete through the admin API; controllers that do not include soft-deleted rows behave as before."
|
||||
- "Per D-27 (G4, error half), a hook, bulk action, record action, toolbar action or widget action that returns *cabana.ForbiddenError is answered 403 with code forbidden, the error's localized Message and its Details, and the surrounding transaction is rolled back; every other non-validation error stays the opaque 500."
|
||||
- "Per D-25, the change ships with modules/pact/README.md, modules/cabana/README.md, the docs/backend pages, admin/openapi/admin.json, admin/src/api/schema.d.ts and a rebuilt modules/boardwalk/dist in the same commits; the fixture uses the neutral plugin id acme.roster and no file names a consuming application; `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` pass."
|
||||
- "The two new routes are in the route inventory, the permission matrix and the OpenAPI document: TestPhase09ContractInventory, TestPhase09PermissionMatrix and TestPhase10OpenAPIConformance pass."
|
||||
- "No Go module and no npm package is added or changed in version by this plan."
|
||||
- "UI S1 empty: With zero permitted bulk actions the bulk menu is not rendered; with nothing selected the trigger is disabled and keeps its label."
|
||||
- "UI S1 loading: While a bulk action is in flight the confirm dialog stays open with both buttons disabled and a spinner on the confirm button until the POST settles, and the menu trigger is disabled."
|
||||
- "UI S1 error: A bulk action failure shows a danger toast: 409 uses `list.bulk_stale`, clears the selection and reloads the list; 403 uses the server message or `list.action_forbidden` and keeps the selection; any other status uses the server message or `extension.action_failed`, keeps the selection and reloads nothing."
|
||||
- "UI S1 populated: Bulk menu items render in declared order at 14/400 with a 40px minimum height and no icons, groups or separators."
|
||||
- "UI S1 partial: A bulk action succeeds or rolls back as a whole; the success toast shows the server's affected count, which may be lower than the selection when the plugin skips rows."
|
||||
- "UI S1 overflow: Many bulk actions extend the menu downward inside the viewport (Reka collision handling) and the toolbar cluster wraps."
|
||||
- "UI S1 zero-one-many: The default bulk confirm and the `list.bulk_done` toast use `:count` with CLDR plural forms; one selected row and many share one flow."
|
||||
- "UI S1 long-text: A long bulk action label wraps inside the 240 to 320px menu and is never truncated; the confirm message wraps inside the dialog."
|
||||
- "UI S2 loading: While a record action runs the confirm dialog is busy and every footer button is disabled."
|
||||
- "UI S2 error: A record action failure: 409 shows the `form.action_stale` toast and reloads the record and hint; 404 shows the form load-failure alert; 403 and other statuses show a danger toast with the server message or the framework fallback."
|
||||
- "UI S4 populated: Each row state renders a text badge in the first cell plus the row text style from the state table; the row background is never changed."
|
||||
- "UI S4 partial: A row may carry any subset of the states; each renders independently and their text styles combine."
|
||||
- "UI S4 overflow: In a first cell with states the text truncates (`min-w-0 truncate`) and badges never shrink; the table keeps its horizontal scroll."
|
||||
- "UI S4 zero-one-many: A row with no state is unchanged; one or several states render badges in the fixed order deleted, negative, disabled."
|
||||
- "UI S4 long-text: Row state badges are `whitespace-nowrap` and never struck through; a long first-cell value truncates before the badges."
|
||||
- "UI S6b empty: A 403 on save with no server message shows the banner with `form.forbidden`."
|
||||
- "UI S6b error: A 403 on create or update saves nothing, keeps every entered value and the dirty state, and shows a persistent role=alert banner (not a toast) that clears on the next save attempt."
|
||||
- "UI S6b partial: When the 403 `error.details` names fields, each is marked through its error line and the first is focused; without details only the banner shows."
|
||||
- "UI S6b overflow: A long forbidden message wraps inside the banner and is never truncated."
|
||||
- "UI S6b long-text: A long forbidden message wraps inside the banner and is never truncated."
|
||||
- statement: "Focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel"
|
||||
verification: backstop
|
||||
- statement: "A row state outside the fixed set renders no badge and no class, and each known state renders its text badge"
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "AdminBulkAction, HasAdminBulkActions, AdminRecordAction, HasAdminRecordActions, RowState, ListRowStates"
|
||||
contains: "HasAdminBulkActions"
|
||||
- path: "modules/cabana/actions.go"
|
||||
provides: "bulkAction and recordAction handlers, ForbiddenError mapping in runAction"
|
||||
contains: "bulkActionOf"
|
||||
- path: "modules/cabana/crud.go"
|
||||
provides: "CRUDService.BulkAction, CRUDService.RecordAction, ForbiddenError, BulkActionResult"
|
||||
contains: "ForbiddenError"
|
||||
- path: "modules/cabana/testdata/roster"
|
||||
provides: "neutral acme.roster fixture tree (controllers/people, models/person, lang)"
|
||||
- path: "modules/cabana/phase121_actions_test.go"
|
||||
provides: "TestBulkAction*, TestListSchemaBulkActions*, TestRecordAction*, TestRowState*, TestForbidden* smoke tests"
|
||||
- path: "admin/src/components/list/BulkActionsMenu.vue"
|
||||
provides: "bulk actions menu per UI-SPEC S1"
|
||||
- path: "admin/src/components/list/RowStateBadges.vue"
|
||||
provides: "row state text badges per UI-SPEC S4"
|
||||
- path: "admin/src/components/form/RecordActions.vue"
|
||||
provides: "record action buttons with confirm and request flow per UI-SPEC S2 (mounted by plan 02 in the preview footer)"
|
||||
- path: "modules/boardwalk/dist"
|
||||
provides: "rebuilt embedded SPA"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/actions.go"
|
||||
via: "POST /{vendor}/{plugin}/{controller}/bulk/{action} and POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}, both wrapped in requireAjax"
|
||||
pattern: "requireAjax\\(s\\.(bulkAction|recordAction)\\)"
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/pact/capabilities.go"
|
||||
via: "BulkAction calls lockScoped and hands loaded records to AdminBulkAction.Run"
|
||||
pattern: "lockScoped"
|
||||
- from: "admin/src/views/ListView.vue"
|
||||
to: "admin/src/components/list/BulkActionsMenu.vue"
|
||||
via: "ListToolbar renders the menu from schema.bulkActions and ListView.onBulkAction posts the selected ids"
|
||||
pattern: "onBulkAction"
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/actions.go"
|
||||
via: "writeCRUDError, lifecycleFailure and runAction each classify *ForbiddenError"
|
||||
pattern: "ForbiddenError"
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
|
||||
@.planning/phases/10-admin-vue-spa/design/README.md
|
||||
@modules/cabana/actions.go
|
||||
@modules/cabana/crud.go
|
||||
|
||||
<interfaces>
|
||||
- `modules/pact/capabilities.go`: `AdminAction{Name, Label, Permissions, Run}`, `AdminActionInput`, `AdminActionResult{Message, Fill}`, `HasAdminActions`, `ListExtendQuery`, `FormExtendQuery`, the Form and Relation hooks, `AdminPartialData`. pact already imports gorm.
|
||||
- `modules/cabana/contracts.go`: `CompiledController{PluginID, Controller, List, Form, Relations, Writable, FieldRelations, Actions map[string]pact.AdminAction, ...}`, `Allows(principal, required)`, `requiredOf(ctl)`, `WriteData`, `WriteError`, `WriteErrorDetails`.
|
||||
- `modules/cabana/actions.go`: `toolbarAction`, `toolbarActionOf`, `allowAction` (403 with `msgForbidden` and `logAuth`), `runAction`, `decodeActionRequest` (strict: UseNumber, DisallowUnknownFields, no trailing tokens), `readScopedRecord`.
|
||||
- `modules/cabana/crud.go`: `BulkDelete` (normalizeIDs, lagoon.Transaction, withTx, lockScoped, partialSelection), `loadRecord` (FormExtendQuery, FOR UPDATE, recordNotFound), `writeCRUDError` (413, 422, 404, 409, else 500), `lifecycleFailure`, `ValidationError{Details}`, `BulkDeleteInput{IDs []any}`, `BulkResult{Deleted}`, `ShowRecord`, `projectFullRecord`.
|
||||
- `modules/cabana/extension.go`: `builtinToolbarActions` (create, delete), `compileActions`, `compileExtension`. `modules/cabana/registry.go`: `compileContributions` validates action permissions with `validatePermissions`.
|
||||
- `modules/cabana/list_schema.go`: `listDocument` (strict decode), `toolbarButtons.UnmarshalYAML`, `compileToolbarButtons`, the `bulk` slice with the built-in delete entry, `localizeBulkActions`. `modules/cabana/schema_types.go`: `BulkAction{Name, Label}`, `ListSchema.BulkActions`, `ToolbarAction`.
|
||||
- `modules/cabana/query.go`: `ExecuteList` (runs on the pool, not in a transaction), `ListMeta{Page, PerPage, Total, LastPage}`, `ListResult`. `modules/cabana/relation_field.go`: `RecordMeta{Labels}`, `RecordEnvelope`, `RecordResult`.
|
||||
- `modules/cabana/messages.go`: `listMessageKeys` / `ListMessages` / `listMessageDefaults` (yaml key, JSON key, default phrase key per message).
|
||||
- `modules/cabana/http.go`: `service.mount` (routes inside `r.GroupRaw(api, []string{"backend"}, ...)`, writes wrapped in `requireAjax`), `listSchema` (per-principal filter of ToolbarActions into a new slice), `show`, `list`, `protect`, `decodeCappedBulk`.
|
||||
- `modules/cabana/admin_openapi.go`: stub functions with swag comments; `AdminIDsRequest{IDs []uint64}`, `Envelope[T]`, `ListEnvelope[T]`, `AdminActionRequest`, `AdminActionResult{Message, Fill}`. `modules/cabana/security_coverage_test.go`: `phase09Routes` (one row per mounted route).
|
||||
- Test harness precedent: `modules/cabana/phase101_actions_test.go` (`actSpy`, `actPlugin`, `actController` with a tenant scope, `actEnv.call` with the auth modes bearer, limited, cookie, cookie-only, `newActEnv`).
|
||||
- SPA: `admin/src/views/ListView.vue` (`onDelete`, `onAction`, `selected`, `partialReload`, `useConfirm`), `admin/src/components/list/ListToolbar.vue` (props `buttons`, `actions`, `busyAction`; emits `delete`, `action`), `admin/src/components/list/DataTable.vue` (props `columns`, `rows`, `rowLink`, `selectable`, `selected`), `admin/src/components/ui/confirm.ts` (`ask(request, run)` keeps the dialog open and busy while `run` settles), `admin/src/components/shell/UserMenu.vue` (DropdownMenu surface classes), `admin/src/views/FormView.vue` (`save`, `showErrors`, `onDelete`), `admin/src/components/form/FormErrorBanner.vue`, `admin/src/api/types.ts` (aliases onto `components['schemas']`), `admin/src/app/i18n.ts` (`t`, `tc`, `message`).
|
||||
- Generation: `scripts/check-admin-openapi.sh` (no argument regenerates `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`; `--check` fails on drift), `npm --prefix admin run build` (writes `modules/boardwalk/dist`), `scripts/check-admin-dist.sh`.
|
||||
</interfaces>
|
||||
</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 constants `RowStateDeleted` ("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`; fields `CompiledController.BulkActions`, `CompiledController.RecordActions`, `BulkAction.Confirm`, `RecordMeta.Actions` (json `actions`, omitempty), `ListMeta.RowStates` (json `row_states`, omitempty), `ListMessages.RowStateDeleted`, `.RowStateNegative`, `.RowStateDisabled`.
|
||||
- YAML keys: `config_list.yaml` `bulkActions:` (list of names) and `messages.rowStateDeleted`, `messages.rowStateNegative`, `messages.rowStateDisabled`; `config_form.yaml` `recordActions:` (list of names).
|
||||
- Routes: `POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/bulk/{action}` (body `AdminIDsRequest`, result `Envelope[BulkActionResult]`); `POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/actions/{action}` (body `{}`, result `Envelope[AdminActionResult]` with an empty `fill`).
|
||||
- OpenAPI stubs `AdminBulkAction`, `AdminRecordAction` in `modules/cabana/admin_openapi.go`; TS aliases `BulkAction`, `BulkActionResult`, `RecordAction` in `admin/src/api/types.ts`.
|
||||
- SPA components: `BulkActionsMenu.vue`, `RowStateBadges.vue`, `RecordActions.vue`; `ListToolbar.vue` props `bulkActions`, `bulkBusy` and event `bulk`; `DataTable.vue` props `rowStates`, `stateLabels`; `ListView.vue` `onBulkAction`; `FormErrorBanner.vue` prop `forbidden`; `FormView.vue` forbidden-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/` (plugin `acme.roster`, controller `acme.roster.people`), `modules/cabana/phase121_fixture_test.go`, `modules/cabana/phase121_actions_test.go`, `modules/cabana/example_actions_test.go`; SPA fixtures `admin/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 `create` and `delete` stay reserved in every kind. This departs from the RESEARCH note "one shared namespace" because D-14 needs `activate` and `unban` both 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 of `ListMeta`), 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}`) fills `meta.actions`; create and update responses omit it (the SPA reads the record again when it opens the preview).
|
||||
- **403 text.** A `ForbiddenError` with an empty `Message` is written with an empty `message`; 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.vue` is 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 the `preview:` 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).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: An admin selects rows in a list, picks a declared bulk action from the Bulk actions menu, confirms, and the selected records change in one scoped transaction</name>
|
||||
<reversibility rating="costly">D-09 (user-confirmed): list YAML and the pact action contract grow; every plugin's list config may come to depend on `bulkActions`, `AdminBulkAction` and the bulk route.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/example_actions_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/list/BulkActionsMenu.vue, admin/src/components/list/ListToolbar.vue, admin/src/views/ListView.vue, admin/tests/smoke/actions.smoke.test.ts, admin/tests/fixtures/roster.list-schema.json, admin/tests/fixtures/roster.list.json, modules/boardwalk/dist, docs/backend/admin-controllers.md, docs/backend/lists-and-filters.md, docs/backend/admin-spa.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S1, Copywriting Contract, "Framework: all new keys"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (sections for capabilities.go, actions.go, crud.go, extension.go and list_schema.go, schema_types.go, http.go, admin_openapi.go, framework tests and fixture, ListToolbar/ListView), modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/phase101_actions_test.go, modules/cabana/bulk_test.go, modules/cabana/testdata/extension/controllers/gadgets/config_list.yaml, modules/cabana/example_controller_test.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/src/views/ListView.vue, admin/src/components/list/ListToolbar.vue, admin/src/components/shell/UserMenu.vue, admin/src/components/ui/confirm.ts, admin/src/components/ui/ConfirmDialog.vue, admin/src/api/types.ts, admin/tests/smoke/list.smoke.test.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/fixtures/extension.list-schema.json, docs/backend/admin-controllers.md, docs/backend/lists-and-filters.md, docs/backend/admin-spa.md, scripts/check-admin-openapi.sh</read_first>
|
||||
<action>Per D-09 and D-25; RESEARCH Pattern 1 and Pattern 3; UI-SPEC S1. Repo: summercms.go; one code commit that also carries the generated outputs, README and docs.
|
||||
|
||||
(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.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: One record offers named record actions that apply to its state, and running one changes the scoped record in a transaction</name>
|
||||
<reversibility rating="costly">D-10 (user-confirmed): the pact contract, config_form.yaml and the record response grow by `AdminRecordAction`, `recordActions` and `meta.actions`.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/example_actions_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/RecordActions.vue, admin/tests/smoke/actions.smoke.test.ts, admin/tests/fixtures/roster.record.json, modules/boardwalk/dist, docs/backend/admin-controllers.md, docs/backend/forms.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S2, Copywriting Contract), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (actions.go "Record action scope", http.go "Per-principal filtering", FormView "Record action request flow"), modules/pact/capabilities.go, modules/cabana/actions.go, modules/cabana/crud.go (ShowRecord, loadRecord, writeCRUDError, partialSelection), modules/cabana/form_schema.go (formConfigDocument, CompileForm), modules/cabana/relation_field.go (RecordMeta, RecordEnvelope, projectRelationFields), modules/cabana/extension.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, admin/src/views/FormView.vue (onDelete), admin/src/components/ui/Button.vue, admin/src/components/ui/confirm.ts, admin/src/api/types.ts, docs/backend/forms.md</read_first>
|
||||
<action>Per D-10; UI-SPEC S2. Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(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.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: List rows show their state (deleted, negative, disabled) as text badges, from one controller call per page</name>
|
||||
<reversibility rating="reversible">D-12: an optional controller hook and an optional meta key; adding a state later is additive.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/capabilities_test.go, modules/pact/README.md, modules/cabana/query.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/messages.go, modules/cabana/list_schema.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/list/RowStateBadges.vue, admin/src/components/list/DataTable.vue, admin/src/views/ListView.vue, admin/tests/smoke/actions.smoke.test.ts, admin/tests/fixtures/roster.list-schema.json, admin/tests/fixtures/roster.list.json, modules/boardwalk/dist, docs/backend/lists-and-filters.md, docs/backend/admin-spa.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S4, Color, Typography), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Pitfall 7; "Row state must be a batch hook"), modules/pact/capabilities.go, modules/cabana/query.go (ExecuteList, ListMeta, ListResult), modules/cabana/crud.go (save, Delete, BulkDelete, deleteRecord, loadRecord, lockScoped), modules/cabana/http.go (list handler, projectRow), modules/cabana/messages.go, modules/cabana/list_schema.go (Localize), modules/cabana/messages_test.go, admin/src/components/list/DataTable.vue, admin/src/components/list/CellValue.vue, admin/src/views/ListView.vue, admin/src/styles/main.css, admin/tests/list/DataTable.test.ts, docs/backend/lists-and-filters.md</read_first>
|
||||
<action>Per D-12; UI-SPEC S4. Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(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.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Plugin code refuses a write with a readable 403: a banner on a refused save, a toast on a refused action, and nothing is saved</name>
|
||||
<reversibility rating="costly">D-27 G4 (user-confirmed): `cabana.ForbiddenError` becomes a public error type every plugin hook and action may return.</reversibility>
|
||||
<files>modules/cabana/crud.go, modules/cabana/actions.go, modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/FormErrorBanner.vue, admin/src/views/FormView.vue, admin/tests/smoke/actions.smoke.test.ts, modules/boardwalk/dist, docs/backend/admin-controllers.md, docs/backend/users-and-permissions.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S6 "Forbidden save", Copywriting Contract), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (crud.go "Error type pattern"; "Error outcomes a plugin may produce"), modules/cabana/crud.go (ValidationError, writeCRUDError, lifecycleFailure, save, Delete, BulkDelete), modules/cabana/actions.go (runAction), modules/cabana/relation.go and modules/cabana/relation_child.go (where relation hook errors are classified and written), modules/cabana/crud_lifecycle_test.go, admin/src/views/FormView.vue (save, showErrors, onDelete), admin/src/components/form/FormErrorBanner.vue, admin/src/components/form/formState.ts (fieldErrors, focusField), admin/tests/form/FormView.test.ts, docs/backend/admin-controllers.md (Hooks), docs/backend/users-and-permissions.md</read_first>
|
||||
<action>Per D-27 (G4, error half) and D-07's "refused with a forbidden error and changes nothing"; UI-SPEC S6 "Forbidden save". Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(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.</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test ./... -count=1` green in summercms.go after every task commit.
|
||||
- `npm --prefix admin run typecheck && npm --prefix admin test` green; `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` clean after every task commit.
|
||||
- `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` pass.
|
||||
- `go test ./modules/cabana -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10OpenAPIConformance)$' -count=1` passes with the two new routes.
|
||||
- The application still builds against the working tree: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Bulk actions, record actions, row state and `ForbiddenError` exist 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.vue` implements 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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md` when done. Record the final names of every contract (they are inputs to plans 02 to 05) and the measured run time of the quick commands for VALIDATION.md.
|
||||
</output>
|
||||
488
.planning/phases/12.1-user-plugin-admin-screens/12.1-02-PLAN.md
Normal file
488
.planning/phases/12.1-user-plugin-admin-screens/12.1-02-PLAN.md
Normal file
@@ -0,0 +1,488 @@
|
||||
---
|
||||
phase: 12.1-user-plugin-admin-screens
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["12.1-01"]
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/capabilities_test.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/messages.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/field_permission.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/filter_schema.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/phase121_fixture_test.go
|
||||
- modules/cabana/phase121_form_test.go
|
||||
- modules/cabana/example_form_seams_test.go
|
||||
- modules/cabana/testdata/roster
|
||||
- modules/cabana/README.md
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/app/winterUrl.ts
|
||||
- admin/src/styles/main.css
|
||||
- admin/src/views/PreviewView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/fields/PasswordField.vue
|
||||
- admin/src/components/form/fields/PermissionEditorField.vue
|
||||
- admin/src/components/form/fields/RelationField.vue
|
||||
- admin/src/components/partial/PartialHost.vue
|
||||
- admin/src/components/list/DataTable.vue
|
||||
- admin/tests/smoke/preview.smoke.test.ts
|
||||
- admin/tests/smoke/seams.smoke.test.ts
|
||||
- admin/tests/fixtures/roster.form-schema.json
|
||||
- admin/tests/fixtures/roster.record.json
|
||||
- admin/tests/fixtures/roster.list-schema.json
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/relation-manager.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/partials-and-widgets.md
|
||||
- docs/backend/admin-spa.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4]
|
||||
estimate:
|
||||
tokens: 320000
|
||||
raw_tokens: 320000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-26, this is plan 02 of five: the preview context, the permissioneditor field and the form seams in summercms.go, ending with the tag v0.1.3; plans 03 to 05 build on that tag."
|
||||
- "Per D-11, config_form.yaml accepts a `preview:` block (optional `headerPartial`), the form schema reports it, fields with `context: preview` are shown only on the preview screen and are never writable, and the SPA has a read-only record screen at the preview route with its own footer (record actions, then one primary edit button), a status hint slot above the card, and `recordUrl`, `create.redirect` and `update.redirectClose` may point at it through `.../preview/:id`."
|
||||
- "Per D-11 and D-10, a config_form.yaml that declares `recordActions` without a `preview:` block stops boot, because record actions are offered only on the preview screen."
|
||||
- "Per D-16, `type: permissioneditor` is a built-in field with `mode: radio` or `mode: checkbox`: the controller supplies the options (code, label, tab, comment, locked) per request, a submitted code outside the options or a value outside the mode's set (radio 1 or -1, checkbox 1) is 422 on the field, a changed locked code is 403, stored codes that are not offered are kept, and the 10.1 widget contract (scalar fill keys only) is unchanged."
|
||||
- "Per D-27 and D-19 (G1, G2), `type: password` is a masked field whose value is never projected into a response and never a fill key, and form virtual fields listed by the controller through pact.FormVirtualFields are exempt from column binding, never filled, never projected, and reach hooks through cabana.VirtualFieldsFromContext only when the field's context allows the operation."
|
||||
- "Per D-28 (G5), a controller implementing pact.FormRules supplies the rule set per operation (create or update) and that set replaces the model's Rules() for admin saves; rules may name virtual fields, whose submitted values (never the model column of the same name) are what the rules see."
|
||||
- "Per D-27 (G3) and D-22, a belongsTo relation field whose foreign key is a protected fill key stays read-only unless its contract sets WritableForeignKey; the protected fill key list itself is unchanged."
|
||||
- "Per D-27 (G4) and D-07, a controller implementing cabana.RelationLockProvider names related ids the current admin may not add or remove: options and labels carry `locked: true`, and a save (create or update) that changes the locked subset is refused with 403 before any row is written and changes nothing."
|
||||
- "Per D-27 (G6, G7) and D-22, columns.yaml accepts `invisible: true` (searchable on the server, not rendered and not sent in rows) and fields.yaml accepts `preset` on a text field (a source field name, or field plus type slug or exact) which the SPA applies on create only while the target is untouched."
|
||||
- "A scope filter's choices may come from the controller: a controller implementing pact.FilterOptions is asked before the model, so choices read from the database need no global handle (found at planning; needed for D-23)."
|
||||
- "Per D-25, every change ships with the module READMEs, the docs/backend pages, the admin OpenAPI document, the generated TS types and a rebuilt modules/boardwalk/dist in the same commit, fixtures use the neutral id acme.roster, and the framework is tagged v0.1.3 only after both full suites, the docs checks and the generated-output checks pass and the user has answered the tag checkpoint."
|
||||
- "No Go module and no npm package is added or changed in version by this plan."
|
||||
- "UI S2 empty: With no applicable record action the preview footer shows only the edit button."
|
||||
- "UI S2 populated: After a record action succeeds a success toast shows and the record and status hint reload in place, with the previous content visible during the reload."
|
||||
- "UI S2 overflow: The preview footer and its right cluster wrap; record action buttons move to a second row instead of shrinking."
|
||||
- "UI S2 zero-one-many: Zero applicable record actions leave only the edit button; one or many render in declared order before it."
|
||||
- "UI S2 long-text: Record action buttons are `whitespace-nowrap` and wrap as whole buttons; a label is never truncated."
|
||||
- "UI S3 empty: On preview an empty value shows the muted empty-value dash, and an empty multiple relation shows the same dash."
|
||||
- "UI S3 loading: While the preview loads the title is blank, the card is not rendered and the footer buttons are disabled; the status hint's first fetch shows one 68px skeleton block and a refetch keeps the previous hint visible."
|
||||
- "UI S3 error: A preview load failure or 404 shows the existing alert with `form.load_failed` and the back button still works; a failed status hint shows the extension failure box and the rest of the screen works."
|
||||
- "UI S3 populated: The preview renders fields as a `dl` grid in the form card with read-only boxes per the PreviewField table."
|
||||
- "UI S3 partial: Preview fields group into the same tabs as the form, and a tab with no preview-visible field is not rendered; a status hint with zero nodes renders nothing and takes no gap."
|
||||
- "UI S3 overflow: Preview values wrap with `overflow-wrap: anywhere`, textarea boxes grow with their text, and the page scrolls under the fixed footer."
|
||||
- "UI S3 zero-one-many: A multiple relation on preview shows the dash for none and wrapping chips for one or many."
|
||||
- "UI S3 long-text: Long preview values and callout text wrap and are never truncated; the title truncates as on the form."
|
||||
- "UI S5 empty: A permission editor with no options renders the read-only box with `permissioneditor.empty`."
|
||||
- "UI S5 error: A 422 on a permission editor renders on the `FormField` error line and the container border becomes `danger`."
|
||||
- "UI S5 populated: Permissions render as sections grouped by tab in one list (no inner tablist; confirmed by the user as the meaning of D-16 'tabbed'), each row with label, comment and a three-segment radio group or a checkbox."
|
||||
- "UI S5 partial: A locked permission row shows its stored value, a disabled control, the lock icon and the `permissioneditor.locked` text while other rows stay editable."
|
||||
- "UI S5 overflow: A permission editor with many permissions has no inner scroll; the page scrolls and section headers are not sticky."
|
||||
- "UI S5 zero-one-many: One tab gives one section with its header; permissions without a tab form a last section labelled `permissioneditor.other`."
|
||||
- "UI S5 long-text: Permission labels and comments wrap in the left column, the control never shrinks, and below 640px the control moves under the label."
|
||||
- "UI S6a partial: With some options locked, locked chips have no remove button and are skipped by Backspace, unlocked chips work as before, and the locked note shows under the field."
|
||||
- "UI S7a empty: A password field is always empty on load, and an empty password on update is not sent."
|
||||
- "UI S7a error: A password 422, including a confirmation mismatch, renders on the field's error line; the SPA does not compare the two fields."
|
||||
- "UI S7a partial: With only one of password and confirmation filled the save is sent as entered and the server's 422 marks the field."
|
||||
- "UI S7a long-text: A long password scrolls inside the input and `pr-12` keeps it clear of the show/hide toggle; every password field is cleared and hidden again after a successful save."
|
||||
- "UI S7b empty: With an empty preset source the target stays empty."
|
||||
- "UI S7b partial: A preset target follows its source until the first manual edit, then stops for the session; on update nothing is filled."
|
||||
- "UI S7b long-text: A long preset source is slugged in full with no length cut in the SPA; the server's validation decides."
|
||||
- statement: "`mapWinterUrl` maps `preview/:id` to the preview route, and opening the preview route of a form without a preview replaces it with the record route"
|
||||
verification: backstop
|
||||
- statement: "Radio mode emits `1` / `-1` and omits inherit, checkbox mode emits `1` and omits unchecked, a locked row cannot change, and codes outside the options are never sent"
|
||||
verification: backstop
|
||||
- statement: "A locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace"
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_permission.go"
|
||||
provides: "permissioneditor compile, lift, validate, locked guard, store and project"
|
||||
contains: "PermissionEditorProvider"
|
||||
- path: "modules/cabana/tx_context.go"
|
||||
provides: "VirtualFieldsFromContext next to TxFromContext"
|
||||
contains: "VirtualFieldsFromContext"
|
||||
- path: "modules/cabana/relation_field.go"
|
||||
provides: "WritableForeignKey, RelationLockProvider, RelationOption.Locked and the locked-id guard"
|
||||
contains: "RelationLockProvider"
|
||||
- path: "admin/src/views/PreviewView.vue"
|
||||
provides: "read-only record screen per UI-SPEC S3 with RecordActions in its footer"
|
||||
- path: "admin/src/components/form/fields/PermissionEditorField.vue"
|
||||
provides: "permissioneditor control per UI-SPEC S5"
|
||||
- path: "admin/src/components/form/fields/PasswordField.vue"
|
||||
provides: "password control per UI-SPEC S7"
|
||||
- path: "modules/cabana/phase121_form_test.go"
|
||||
provides: "TestPreview*, TestPasswordField*, TestVirtualFields*, TestFormRules*, TestPreset*, TestPermissionEditor*, TestRelationLock*, TestWritableForeignKey*, TestInvisibleColumn*, TestFilterOptionsController* smoke tests"
|
||||
- path: "modules/boardwalk/dist"
|
||||
provides: "rebuilt embedded SPA at the tagged commit"
|
||||
key_links:
|
||||
- from: "admin/src/app/router.ts"
|
||||
to: "admin/src/views/PreviewView.vue"
|
||||
via: "route named preview, listed in CONTROLLER_ROUTES"
|
||||
pattern: "name: 'preview'"
|
||||
- from: "admin/src/app/winterUrl.ts"
|
||||
to: "admin/src/app/router.ts"
|
||||
via: "preview/:id maps to the preview route of the current controller only"
|
||||
pattern: "preview"
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/relation_field.go"
|
||||
via: "save calls the locked-id guard after checkRelationScope and before any row write"
|
||||
pattern: "checkRelationLocks"
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/field_permission.go"
|
||||
via: "save lifts, validates and stores permissioneditor values; projectFullRecord projects them"
|
||||
pattern: "liftPermissionValues"
|
||||
- from: "admin/src/views/PreviewView.vue"
|
||||
to: "admin/src/components/form/RecordActions.vue"
|
||||
via: "the footer renders the actions offered in the record response meta.actions"
|
||||
pattern: "RecordActions"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): 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 form can have a preview screen with status hints and record actions, a password field with confirmation, form-only fields that reach hooks, its own admin validation rules, a permission editor, a writable foreign-key picker, locked relation options that the server enforces, preset fields and hidden search columns. The framework is then tagged v0.1.3.
|
||||
|
||||
<objective>
|
||||
Add the preview context (D-11), the `permissioneditor` field (D-16) and the form seams G1 to G7 (D-27, D-28) to `modules/pact`, `modules/cabana` and the admin SPA, with the same documentation and generated-output duties as plan 01, then tag the framework v0.1.3 behind a user decision checkpoint.
|
||||
|
||||
Purpose: locked decisions D-07, D-19, D-22 and D-23 cannot be met by the plugin screens without these seams (RESEARCH "Framework Gaps Beyond CONTEXT.md").
|
||||
Output: the contracts and keys listed under "Artifacts this phase produces", an extended `acme.roster` fixture, smoke tests, updated READMEs and docs, and the annotated tag `v0.1.3`.
|
||||
|
||||
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. 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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md
|
||||
@.planning/phases/10-admin-vue-spa/design/README.md
|
||||
@modules/cabana/crud.go
|
||||
@modules/cabana/form_schema.go
|
||||
|
||||
<interfaces>
|
||||
- From plan 01 (see 12.1-01-SUMMARY.md for the final names): `pact.AdminRecordAction`, `pact.HasAdminRecordActions`, `cabana.RecordAction`, `RecordMeta.Actions`, `cabana.ForbiddenError{Message, Details}` (403, localized by the service, rolls the write back), the `acme.roster` fixture (`rosterPlugin`, `rosterController`, `rosterPerson`, `newRosterEnv`), `admin/src/components/form/RecordActions.vue` (props `source`, `recordId`, `actions`, `disabled`; events `busy`, `done`, `stale`, `gone`), `FormErrorBanner` prop `forbidden`.
|
||||
- `modules/cabana/form_schema.go`: `formFieldTypes`, `formFieldKeys`, `formConfigDocument{Name, Form, ModelClass, DefaultRedirect, Create, Update, Messages}` (strict decode), `compileFieldNode`, `compileContext`, `FormSchema.Localize(ctx, tr, provider)`, `FormView`.
|
||||
- `modules/cabana/crud.go`: `save` pipeline (liftRelationValues, transaction, loadRecord, lagoon.Fill, BeforeValidate, `mergedRules`, `valuesForRules`, lagoon.Validate, formBefore*, checkRelationScope, assignBelongsTo, Save or Create, syncBelongsToMany, commitDeferred, formAfter*, projectFullRecord), `BindWritableFields`, `scalarFormField`, `protectedFillKey` (the list stays as it is), `projectRecord`, `contextAllows(cc, name, op)`.
|
||||
- `modules/cabana/tx_context.go`: `withTx`, `TxFromContext` and their unexported key type.
|
||||
- `modules/cabana/field_file.go`: the rule "mode is only valid on type: fileupload or datepicker" inside compileFileuploadKeys. `modules/cabana/field_date.go`: the one-file-per-field-type pattern (`compileDatepickerKeys`, `compileDateFields`).
|
||||
- `modules/cabana/relation_field.go`: `FieldRelationContract{Field, Kind, NewRelated, ForeignKey, NewPivot, ParentForeignKey, RelatedForeignKey, OrderColumn, LabelColumn}`, `RelationOption{Value, Label}`, `compileFieldRelation` (the line setting ReadOnly from protectedFillKey), `RelationOptions`, `liftRelationValues`, `checkRelationScope`, `assignBelongsTo` (its own protectedFillKey guard), `syncBelongsToMany`, `projectRelationFields`, `relationLabels`.
|
||||
- `modules/cabana/list_schema.go`: `columnDocument{Label, Searchable, Sortable, Type, Relation, Select}`, `compileColumns`, `ListColumn`. `modules/cabana/http.go`: `projectRow`, `formSchema` (per-request field filtering into a new slice). `modules/cabana/filter_schema.go`: `validateFilter`, `filterOptions`, `filterProvider` (model only today). `modules/pact/capabilities.go`: `FilterOptions{FilterOptions(scope string) []Option}`.
|
||||
- `modules/cabana/extension.go`: `compilePartials` (headerPartial and `type: partial` names, `cc.formPartials`). `modules/cabana/partial_render.go`: the partial route accepts `?id=` only for names in `cc.formPartials` and loads the record with `readScopedRecord`.
|
||||
- `modules/cabana/messages.go`: `formMessageKeys`, `FormMessages`, `formMessageDefaults`.
|
||||
- SPA: `admin/src/app/router.ts` (`CONTROLLER_ROUTES`, the `record` route), `admin/src/app/winterUrl.ts` (`mapWinterUrl`, the `update/:id` branch, `DIGITS`), `admin/src/views/FormView.vue` (`mode`, `fields`, `tabs`, `recordName`, `redirectTarget`, `onLeave`, `save`, `adopt`), `admin/src/components/form/formState.ts` (`FormMode`, `contextAllows`, `editablePayload`, `initialValues`), `admin/src/components/form/registry.ts` (`renderers`, `valueless`, `groupLabelledTypes`), `admin/src/components/form/control.ts` (`FieldControlProps`, `controlClass`, `controlAttributes`), `admin/src/components/form/fields/TextField.vue`, `DropdownField.vue`, `RelationField.vue`, `admin/src/components/form/FormTabs.vue`, `FormGrid.vue`, `admin/src/components/partial/PartialHost.vue` (props `source`, `name`, `recordId`, `variant`, `reloadKey`), `admin/src/components/list/CellValue.vue` (the Yes and No pills), `admin/src/styles/main.css` (`@theme` tokens, `@layer components` partial style kit).
|
||||
- Reka UI 2.9.10 exports used: RadioGroupRoot, RadioGroupItem, CheckboxRoot, CheckboxIndicator; lucide icons Pencil, ArrowLeft, Lock, Eye, EyeOff, Check, CircleAlert.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share. The names below are fixed for plans 03 to 05.)
|
||||
|
||||
- pact interfaces: `FormVirtualFields{FormVirtualFields() []string}`, `FormRules{FormRules(ctx context.Context, op string) map[string]string}`; the `FilterOptions` doc comment now says the admin controller may implement it and is asked before the model.
|
||||
- cabana types and functions: `VirtualFieldsFromContext(ctx) (map[string]any, bool)`; `FormPreview{HeaderPartial}` and `FormView.Preview` (json `preview`, omitempty); `FormMessages.Preview`, `FormMessages.Edit`; `FieldPreset{Field, Type}` and `FormField.Preset`; `PermissionOption{Code, Label, Tab, Comment, Locked}`, `FormField.PermissionOptions`, `PermissionEditorProvider{AdminPermissionOptions, AdminPermissionValues, AdminSetPermissionValues}`; `FieldRelationContract.WritableForeignKey`; `RelationLock{IDs []uint; Message string}`, `RelationLockProvider{AdminRelationLocks(ctx context.Context, field string) (RelationLock, error)}`, `RelationOption.Locked` (json `locked`, omitempty); `ListColumn.Invisible` (json `invisible`, omitempty).
|
||||
- YAML keys: `config_form.yaml` `preview:` (mapping; optional `headerPartial`) and `messages.preview`, `messages.edit`; `fields.yaml` types `password` and `permissioneditor`, key `preset`, `mode: radio|checkbox` on `permissioneditor`; `columns.yaml` key `invisible`.
|
||||
- SPA: route `preview` (`/:vendor/:plugin/:controller/:id/preview`), `PreviewView.vue`, `PreviewField.vue`, `PasswordField.vue`, `PermissionEditorField.vue`; `FormMode` gains `preview`; `mapWinterUrl` learns `preview/:id`; `RelationField.vue` locked options; partial style kit classes `.summer-callout`, `.summer-callout--warning`, `.summer-callout--danger`, `.summer-callout__title`, `.summer-callout__text`; TS aliases `FormPreview`, `FieldPreset`, `PermissionOption`.
|
||||
- Phrase keys (en, pl): `backend::lang.form.{return_to_preview, locked_item, locked_note, show_password, hide_password}`, `backend::lang.permissioneditor.{allow, inherit, deny, locked, empty, other}`, `backend::lang.messages.form.{preview, edit}`.
|
||||
- Tests and fixture: `modules/cabana/phase121_form_test.go`, `modules/cabana/example_form_seams_test.go`, the extended `modules/cabana/testdata/roster` tree, `admin/tests/smoke/preview.smoke.test.ts`, `admin/tests/smoke/seams.smoke.test.ts`.
|
||||
- Release: annotated git tag `v0.1.3` on summercms.go.
|
||||
|
||||
## Planner decisions recorded for this plan
|
||||
|
||||
- **Preview view.** A separate `PreviewView.vue` (UI-SPEC leaves the choice open); `FormView.vue` stays the create and update screen.
|
||||
- **`preview:` shape.** A mapping; write `preview: {}` to enable the screen without a hint. An empty (null) `preview:` is a boot error, so the key can never be half-set by accident.
|
||||
- **Permission editor storage.** The framework lifts, validates and guards the value; reading and writing the model's column goes through the controller (`AdminPermissionValues`, `AdminSetPermissionValues`), so the stored JSON shape stays the plugin's decision (D-15 needs PHP's shapes). Stored codes that are not in the option list are kept unchanged.
|
||||
- **Virtual field values in rules.** For a field listed in `FormVirtualFields`, validation sees the submitted value or nothing; the model column of the same name (for example a password hash) is never read.
|
||||
- **Filter options on the controller (new, found at planning).** `pact.FilterOptions` has no database handle and was resolved on the model only, so database-backed choices (the Users `groups` filter, D-23) were not possible. Resolving it on the controller first mirrors `DropdownOptionsProvider` and adds no exported identifier. It is listed in the tag checkpoint so the user sees it before the contract is published.
|
||||
- **Spec-less probe fallback: skipped** (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" and the UI-SPEC "UI Considerations" rows lifted into `must_haves`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: A list row opens a read-only preview screen with a status hint, preview-only fields, record action buttons and an edit button</name>
|
||||
<reversibility rating="costly">D-11 (user-confirmed): a new SPA route and screen, a new config_form.yaml key and a new context value that plugin YAML and redirects will depend on.</reversibility>
|
||||
<files>modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/messages.go, modules/cabana/extension.go, modules/cabana/partial_render.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/router.ts, admin/src/app/winterUrl.ts, admin/src/styles/main.css, admin/src/views/PreviewView.vue, admin/src/views/FormView.vue, admin/src/components/form/PreviewField.vue, admin/src/components/form/formState.ts, admin/src/components/partial/PartialHost.vue, admin/tests/smoke/preview.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, admin/tests/fixtures/roster.record.json, modules/boardwalk/dist, docs/backend/forms.md, docs/backend/partials-and-widgets.md, docs/backend/admin-spa.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S2, S3, "Partial style kit addition", Copywriting Contract), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (FormView, router and winterUrl section), .planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/messages.go, modules/cabana/extension.go (compilePartials, compileExtension), modules/cabana/partial_render.go, modules/cabana/crud.go (contextAllows, projectRecord), modules/cabana/phase121_fixture_test.go, modules/cabana/testdata/extension/controllers/gadgets/_stats.htm, admin/src/app/router.ts, admin/src/app/winterUrl.ts, admin/src/views/FormView.vue, admin/src/views/ListView.vue (rowLink), admin/src/components/form/formState.ts, admin/src/components/form/FormGrid.vue, admin/src/components/form/FormTabs.vue, admin/src/components/form/FormField.vue, admin/src/components/form/RecordActions.vue, admin/src/components/partial/PartialHost.vue, admin/src/components/partial/partialNodes.ts, admin/src/components/list/CellValue.vue, admin/src/components/form/fields/DatepickerField.vue and FileuploadField.vue (their read-only modes), admin/src/styles/main.css, admin/tests/app/winterUrl.test.ts, admin/tests/app/router.test.ts, admin/tests/smoke/edit.smoke.test.ts, docs/backend/forms.md, docs/backend/partials-and-widgets.md</read_first>
|
||||
<action>Per D-11 and D-10; UI-SPEC S2 and S3. Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(1) Server. form_schema.go: formConfigDocument gains yaml key `preview` (a mapping with the single optional key `headerPartial`; a null value is the boot error "preview must be a mapping; write preview: {} to enable the preview screen without a header partial"; any other key is refused by strict decoding; headerPartial must be a bare partial name under the same rule and hint as list headerPartial). FormSchema keeps the preview config; schema_types.go: `FormPreview` with field HeaderPartial (json headerPartial, omitempty) and `FormView.Preview` as a pointer (json preview, omitempty), copied by Localize. messages.go: formMessageKeys, FormMessages and formMessageDefaults gain Preview and Edit (yaml and JSON keys preview and edit; defaults backend::lang.messages.form.preview and backend::lang.messages.form.edit). extension.go compilePartials: the preview header partial joins the controller's partial names and formPartials (so the partial route accepts it with a record id and loads the record through the form scope); the same boot errors apply (missing template, no pact.AdminPartialData). compileExtension: a form that declares recordActions without a preview block is the boot error "recordActions needs a preview block (record actions are offered on the preview screen)". A field with `context: preview` already compiles; no write path may accept it: contextAllows is only ever asked for create or update. Swag: the AdminFormSchema description gains one sentence on `preview`. Regenerate the OpenAPI outputs; add alias `FormPreview` to admin/src/api/types.ts.
|
||||
|
||||
(2) Phrase keys in en and pl with the UI-SPEC copy: messages.form.preview ("Record preview" / "Podgląd rekordu"), messages.form.edit ("Edit record" / "Edytuj rekord"), form.return_to_preview ("Back to preview" / "Wróć do podglądu").
|
||||
|
||||
(3) Fixture: roster config_form.yaml gains a preview block with headerPartial `status`, messages preview and edit, and create.redirect plus update.redirectClose pointing at acme/roster/people/preview/:id; config_list.yaml recordUrl points at the same preview URL; fields.yaml gains `joined_ip` (type text, context preview, a new nullable column on rosterPerson); new template controllers/people/_status.htm using the summer-callout classes with role status; the roster controller implements PartialData for `status` with a curated view model (title and text phrase keys chosen from banned, deleted or inactive; an empty view model when none applies). Smoke test `TestPreviewSmoke` in modules/cabana/phase121_form_test.go: the form schema carries preview.headerPartial status and the two messages; an update body carrying joined_ip leaves the column unchanged; the show response carries joined_ip; the partial route with the record id renders the callout for a banned person and answers 404 for a person of another tenant; a form YAML with recordActions and no preview fails boot; a null preview fails boot.
|
||||
|
||||
(4) SPA routing. router.ts: route path /:vendor/:plugin/:controller/:id/preview with the same digits constraint as the record route, name `preview`, component PreviewView, meta shell true; CONTROLLER_ROUTES gains `preview`. winterUrl.ts: a `preview/:id` branch copying the update branch (digits only, same controller only, anything else falls back to the list); update the header comment table. formState.ts: FormMode gains `preview`.
|
||||
|
||||
(5) SPA screen per UI-SPEC S3. New admin/src/components/form/PreviewField.vue: one field as a labelled pair (dt with weight 600 and no required mark, dd) rendered by type exactly as the S3 table: text, number and dropdown in the read-only box (dropdown shows the option label); textarea in the growing box with preserved line breaks; checkbox and switch as the Yes or No pills from CellValue; datepicker and fileupload in their existing read-only modes; single relation as the box with its label or the muted empty option or dash; multiple relation as wrapping chips without remove buttons or the dash; partial rendered as on a form; permissioneditor rendered through its field component with every control disabled (available after Task 3; until then the type falls through to the read-only box); password, widget and relation-manager fields are not rendered. An empty value shows the muted dash from backend::lang.list.empty_value. New admin/src/views/PreviewView.vue: loads the form schema and the record in parallel; when the schema has no preview it replaces the route with the record route; frame, header (40px outline back button to the list with aria-label backend::lang.form.return_to_list, the record name as title, subtitle from messages.preview, FormTabs when the visible fields declare tabs), status hint slot (PartialHost variant header with the record id and a reload key, between header and card, outside every tab; nothing and no gap when the partial has zero nodes; one 68px skeleton block on the first fetch; the previous hint stays visible on a refetch; the extension failure box on failure), card with the field grid as a dl using the FormGrid column and span rules, and the fixed footer with nothing on the left and, on the right, RecordActions fed from the record response's meta.actions followed by the single primary button (Pencil icon, label from messages.edit, data-action="edit", linking to the record route). Fields shown are those whose context allows `preview`; a tab with no such field is not rendered. On RecordActions `done`: success toast, reload the record (values, labels, offered actions) keeping the previous content visible, and bump the hint reload key; on `stale`: reload the record and the hint; on `gone` or a failed load: the existing load-failure alert with backend::lang.form.load_failed, the back button still works. While an action runs every footer button is disabled. The footer wraps; buttons never shrink. PartialHost.vue changes only as far as the skeleton and keep-previous behaviour of the header variant on this screen need. FormView.vue: when the schema has a preview and the mode is update, the back arrow and Cancel go to the preview route (aria-label backend::lang.form.return_to_preview) with the unsaved-changes confirm as today; after a delete the form still goes to the list.
|
||||
|
||||
(6) Partial style kit: admin/src/styles/main.css `@layer components` gains the five summer-callout classes with exactly the rules of the UI-SPEC table (they read only the public colour variables; no new token).
|
||||
|
||||
(7) SPA smoke test admin/tests/smoke/preview.smoke.test.ts with fixtures roster.form-schema.json and roster.record.json: the preview route renders the title, the dl grid, the preview-only field and no input for it; the footer shows the offered record action before the primary edit button, and only the edit button when none is offered; an empty value shows the dash; mapWinterUrl maps the fixture's preview/:id; a schema without preview replaces the route with the record route; on the update form with a preview the back button leads to the preview route.
|
||||
|
||||
(8) Docs and READMEs in the same commit: docs/backend/forms.md new section "Preview screen" (the preview block, context preview, messages preview and edit, redirects, where record actions appear, a YAML fence that is a `src=` reference to the roster config_form.yaml); docs/backend/partials-and-widgets.md and the "Partial style kit" section of modules/cabana/README.md (the summer-callout classes and the recommended markup, written as a `src=` fence of the fixture template); docs/backend/admin-spa.md (the preview route). Rebuild and commit modules/boardwalk/dist.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/cabana -run '^(TestPreview|TestRecordAction|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</automated>
|
||||
<fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestPreviewSmoke" 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>
|
||||
<human-check>
|
||||
<test>Run the development application against this framework tree, open a list whose recordUrl points at preview, click a row, read the screen in light and dark mode, run a record action, then press the edit button and Cancel.</test>
|
||||
<expected>The preview matches UI-SPEC S3 (title, hint callout as the only tinted block, dl grid, one primary button at the bottom right), the record action reloads the record in place without a skeleton flash, and Cancel on the update form returns to the preview.</expected>
|
||||
<why_human>Visual fit with Direction C and the in-place reload feel cannot be asserted by unit tests.</why_human>
|
||||
</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test ./modules/cabana -run '^TestPreviewSmoke$' -count=1 -v` prints "--- PASS: TestPreviewSmoke"; the test asserts that a `context: preview` field is not written by an update and that recordActions without preview fails boot.
|
||||
- `grep -c "name: 'preview'" admin/src/app/router.ts` prints 1 and `grep -c "'preview'" admin/src/app/winterUrl.ts` prints at least 1.
|
||||
- `grep -c 'summer-callout--warning' admin/src/styles/main.css` prints 1.
|
||||
- `grep -c 'return_to_preview' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
- `test -f admin/src/views/PreviewView.vue && test -f admin/src/components/form/PreviewField.vue` succeeds and `npm --prefix admin test -- tests/smoke/preview` reports the smoke file passed.
|
||||
- `scripts/check-admin-dist.sh` prints "modules/boardwalk/dist matches a fresh build".
|
||||
</acceptance_criteria>
|
||||
<done>A plugin can give a form a preview screen: rows open it, it shows the record read-only with a status hint and the record actions of plan 01, and its edit button leads to the update form.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: A form takes a password with confirmation and form-only fields that reach the plugin's hooks, validated by the controller's own rules, and a text field presets from another</name>
|
||||
<reversibility rating="costly">D-27 G1, G2, G7 and D-28 G5 (user-confirmed): two new field types and keys in the typed schema and two new pact interfaces.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/capabilities_test.go, modules/pact/README.md, modules/cabana/contracts.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/crud.go, modules/cabana/tx_context.go, modules/cabana/extension.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/components/form/fields/PasswordField.vue, admin/src/views/FormView.vue, admin/tests/smoke/seams.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, modules/boardwalk/dist, docs/backend/forms.md, docs/backend/admin-controllers.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S7), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("Framework Gaps Beyond CONTEXT.md" G1, G2, G5, G7; Pitfalls 1 and 2), modules/pact/capabilities.go (the Form hooks), modules/cabana/crud.go (BindWritableFields, save, mergedRules, valuesForRules, projectRecord, projectOperation, fillAllowed, scalarFormField, protectedFillKey), modules/cabana/tx_context.go, modules/cabana/form_schema.go (formFieldTypes, formFieldKeys, compileFieldNode), modules/cabana/schema_types.go (FormField), modules/cabana/extension.go, modules/lagoon/validate.go (the supported rule tokens and how `confirmed` and `unique` read values), modules/cabana/crud_lifecycle_test.go, admin/src/components/form/fields/TextField.vue, admin/src/components/form/control.ts, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/views/FormView.vue, admin/tests/form/fields.test.ts, docs/backend/forms.md, docs/backend/admin-controllers.md</read_first>
|
||||
<action>Per D-27 (G1, G2, G7), D-28 (G5) and D-19; UI-SPEC S7. Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(1) pact: interface `FormVirtualFields` with FormVirtualFields() []string (field names of the controller's form that are not model columns for the purpose of the form: they are never bound, filled or projected, and their submitted values reach the Form hooks through cabana.VirtualFieldsFromContext); interface `FormRules` with FormRules(ctx context.Context, op string) map[string]string where op is create or update (when implemented, the returned set replaces the model's Rules() for admin saves; rule strings use the tokens lagoon.Validate supports; rules may name virtual fields).
|
||||
|
||||
(2) Field type `password` (G1). form_schema.go: `password` joins formFieldTypes; it is not a scalar form field for binding. extension.go: a `password` field the controller does not list in FormVirtualFields is the boot error "field NAME: type password needs the controller to list it in FormVirtualFields"; a field listed in FormVirtualFields that is not in fields.yaml, or whose type is not one of password, text, textarea, number, checkbox, switch or dropdown, is a boot error naming the field. A password value is never part of a record response on any route and never a fill key.
|
||||
|
||||
(3) Virtual fields (G2). contracts.go: CompiledController gains the set of virtual field names. crud.go BindWritableFields: a virtual field is skipped (no column check, no writable binding), so Fill never receives it and projectRecord never returns it. save: before the transaction, collect the submitted values of virtual fields whose context allows the operation and that are present in the body; a nested (object or list) value is validation_failed on that field with the text "The NAME field has an invalid value."; place the resulting map on the transaction context. tx_context.go: `VirtualFieldsFromContext(ctx) (map[string]any, bool)` next to TxFromContext, returning a copy; it is false outside a create or update save. The Form hooks (before and after create and update) read it from the context they already receive.
|
||||
|
||||
(4) Rules per operation (G5). crud.go mergedRules: when the controller implements pact.FormRules its result for the operation is the base set instead of the model's Rules(); the form's `required` flags still merge for scalar fields and now also for virtual fields whose context allows the operation. valuesForRules: for a field in the virtual set the value is the submitted virtual value, or absent when it was not submitted; the model column of the same name is never read for it. Everything else in the pipeline is unchanged; newWritableModel still requires the model to implement Rules().
|
||||
|
||||
(5) Preset (G7). form_schema.go: `preset` joins formFieldKeys; accepted as a string (the source field name; type slug) or a mapping with keys field and type; type is slug or exact, anything else is the boot error "preset type NAME is not supported (want slug or exact)"; preset on a type other than text is "preset is only valid on type: text"; extension.go checks that the source is a text field of the same form and not the field itself. schema_types.go: `FieldPreset` (Field json field, Type json type) and `FormField.Preset` as a pointer with omitempty.
|
||||
|
||||
(6) Regenerate the OpenAPI outputs; add alias `FieldPreset` to admin/src/api/types.ts.
|
||||
|
||||
(7) Fixture and smoke tests. rosterPerson gains a `password` column (a stored hash, json "-") and a `slug` column; fields.yaml gains `password` (type password, context create and update), `password_confirmation` (type password, context create and update), `notify` (type checkbox, default true, context create) and `slug` (type text, preset name); the roster controller lists password, password_confirmation and notify in FormVirtualFields, implements FormRules (create: password required, between 8 and 255, confirmed; update: password nullable, between 8 and 255, confirmed; name required) and in FormBeforeCreate and FormBeforeUpdate stores a derived hash when a password was submitted and records `notify` in the spy. `TestPasswordFieldSmoke` (create with matching passwords answers 201, the stored hash differs from the plain text, no response on create, show, update or list contains the key password or the plain text; a mismatch answers 422 on password), `TestVirtualFieldsSmoke` (the hook saw notify true on create; notify sent on update never reaches the hook because its context is create; a nested value answers 422; none of the three names appears in any response), `TestFormRulesSmoke` (an update changing only name answers 200 although the model's own Rules would demand a confirmed password; a virtual field not listed by the controller fails boot), `TestPresetSchema` (the form schema carries preset field name and type slug; an unsupported type and a non-text target fail boot). modules/cabana/example_form_seams_test.go: a compiled example controller with FormVirtualFields and FormRules, for the docs.
|
||||
|
||||
(8) SPA per UI-SPEC S7. New admin/src/components/form/fields/PasswordField.vue: a relative wrapper, an input of type password with controlClass plus the input height and right padding 12, autocomplete new-password and spellcheck false, and the 32 by 32 show/hide toggle inside the right edge with the Eye and EyeOff icons, aria-pressed and the labels backend::lang.form.show_password and hide_password, reachable by Tab after the input. registry.ts: `password` is a plain value field (one registry line; extend the header comment with a Phase 12.1 sentence). formState.ts editablePayload: an empty password value is not sent on update and is sent as entered on create. FormView.vue: after a successful save every password field value is cleared and its control returns to hidden; a non-empty password marks the form dirty; the SPA never compares password and confirmation. Preset: on create only, while the target text field has not been edited by hand, each input on the source field sets the target (type slug: lower-case ASCII, every run of other characters becomes one hyphen, hyphens trimmed at both ends; type exact: the same text); the first manual edit of the target stops it for the session; an empty source leaves the target empty; on update nothing is filled; no visual indicator. Phrase keys form.show_password and form.hide_password in en and pl. Smoke test admin/tests/smoke/seams.smoke.test.ts: the password field renders empty although the record fixture is loaded, toggling shows the text, an empty password is absent from the update body, both password fields are empty after a successful save; typing a name on create fills slug with the slugged text until slug is edited by hand; on update slug does not follow.
|
||||
|
||||
(9) Docs and READMEs in the same commit: modules/pact/README.md (FormVirtualFields, FormRules); modules/cabana/README.md (Features, API reference: VirtualFieldsFromContext, FieldPreset); docs/backend/forms.md (field types table rows for password, a section "Form-only fields" on virtual fields, the `preset` key, and "What a save may write" extended: virtual fields are never written by the framework); docs/backend/admin-controllers.md Hooks section (reading virtual values, rules per operation, with `src=` fences from example_form_seams_test.go). Rebuild and commit modules/boardwalk/dist.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana/ ./modules/pact/ && go test ./modules/pact/... -count=1 && go test ./modules/cabana -run '^(TestPasswordField|TestVirtualFields|TestFormRules|TestPreset|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</automated>
|
||||
<fails_when>Any command exits non-zero; the named cabana run lacks any of "--- PASS: TestPasswordFieldSmoke", "--- PASS: TestVirtualFieldsSmoke", "--- PASS: TestFormRulesSmoke", "--- PASS: TestPresetSchema" 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>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go doc ./modules/pact FormVirtualFields`, `go doc ./modules/pact FormRules` and `go doc ./modules/cabana VirtualFieldsFromContext` each print a declaration.
|
||||
- `go test ./modules/cabana -run '^TestPasswordFieldSmoke$' -count=1 -v` prints "--- PASS: TestPasswordFieldSmoke"; the test asserts that no create, show, update or list response body contains the submitted plain-text password or a password key.
|
||||
- `go test ./modules/cabana -run '^TestFormRulesSmoke$' -count=1 -v` prints "--- PASS: TestFormRulesSmoke"; the test asserts that an update of name alone answers 200.
|
||||
- `grep -c "\['password', PasswordField\]" admin/src/components/form/registry.ts` prints 1.
|
||||
- `grep -c 'hide_password' modules/phrasebook/backend/lang/en/lang.yaml` prints 1.
|
||||
- `npm --prefix admin test -- tests/smoke/seams` reports the smoke file passed.
|
||||
- The protected fill key list in modules/cabana/crud.go still contains password, permissions, is_activated and organisation_id (`go test ./modules/cabana -run '^TestCRUD' -count=1` passes unchanged).
|
||||
</acceptance_criteria>
|
||||
<done>A plugin form can collect a password with confirmation and other form-only values, validate them with rules of its own per operation, and receive them in its hooks; none of them is ever written or returned by the framework itself.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: A form shows a permission editor in radio or checkbox mode, and the server stores only codes and values the controller offers</name>
|
||||
<reversibility rating="costly">D-16 (user-confirmed): a new field type in the typed schema and the generated TS types, built to be reused for backend role permissions.</reversibility>
|
||||
<files>modules/cabana/field_permission.go, modules/cabana/field_file.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/contracts.go, modules/cabana/crud.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/components/form/registry.ts, admin/src/components/form/fields/PermissionEditorField.vue, admin/src/components/form/PreviewField.vue, admin/tests/smoke/seams.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, admin/tests/fixtures/roster.record.json, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S5, Color, Typography, Spacing Scale), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (field_permission.go and form_schema.go section; PermissionEditorField analog), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Pitfalls 5 and 10), modules/cabana/field_date.go, modules/cabana/field_file.go (compileFileuploadKeys and the mode rule), modules/cabana/relation_field.go (liftRelationValues, checkRelationScope), modules/cabana/crud.go (save, projectFullRecord), modules/cabana/http.go (formSchema), modules/cabana/registry.go (compileRegistry order), admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/WidgetField.vue (a group-labelled control), admin/src/components/form/control.ts, admin/src/components/form/registry.ts, admin/src/components/form/FormField.vue, admin/src/components/list/DataTable.vue (the checkbox box classes), docs/backend/forms.md, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/formwidgets/FrontendPermissionEditor.php and its partials (read-only reference for grouping and modes)</read_first>
|
||||
<action>Per D-16; UI-SPEC S5; RESEARCH Pitfall 10. Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(1) Schema. form_schema.go: `permissioneditor` joins formFieldTypes. New modules/cabana/field_permission.go holds the type's code, in the one-file-per-type pattern of field_date.go: `compilePermissionKeys(typ, values, field)` called from compileFieldNode (on a permissioneditor field `mode` is required and must be radio or checkbox, message "mode must be radio or checkbox on type: permissioneditor"; the keys options, default, nameFrom, emptyOption, relation and preset are refused on it); the rule in field_file.go that refuses `mode` becomes "mode is only valid on type: fileupload, datepicker or permissioneditor". Exported types: `PermissionOption` with Code, Label, Tab, Comment (strings) and Locked (bool; json keys code, label, tab, comment, locked with omitempty on tab, comment and locked); interface `PermissionEditorProvider` with three methods: AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error), AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error) and AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error. `compilePermissionFields(pluginID, cc)` (called from compileRegistry next to compileDateFields) stops boot when a form has a permissioneditor field and the controller does not implement PermissionEditorProvider. schema_types.go: FormField gains `PermissionOptions []PermissionOption` (json permissionOptions, omitempty).
|
||||
|
||||
(2) Per-request options. http.go formSchema: for each permissioneditor field call AdminPermissionOptions with the request context, localize Label, Tab and Comment, and set the options on the localized copy in a new slice (the cached schema is never mutated); a provider error answers the generic 500. Options keep the order the controller returns.
|
||||
|
||||
(3) Save path in crud.go and field_permission.go. `liftPermissionValues(cc, body, op)`: for each permissioneditor field present in the body and allowed by its context, the value must be a JSON object of code to integer (integers may arrive as numbers; strings, booleans, fractions, nested values or a non-object are validation_failed on the field with the text "The NAME field must be an object of permission codes."); the lifted value never goes through scalar projection. Inside the transaction, after the Form before-hooks and before the row write: load the options, then check every submitted code is offered (else 422 "The NAME field contains an unknown permission.") and every value is in the mode's set (radio: 1 or -1, a 0 is treated as absent; checkbox: 1, a 0 is treated as absent; anything else 422 "The NAME field contains an invalid value."); read the stored values through AdminPermissionValues (an empty map on create); for every option marked Locked the stored and the submitted value must be equal, else return a ForbiddenError with Details on the field; build the next set as the stored codes that are not offered plus the submitted codes, and hand it to AdminSetPermissionValues before tx.Save or tx.Create. Projection: projectFullRecord sets data[field] from AdminPermissionValues for every permissioneditor field, on show, create and update, as an object (never null). A field absent from the body is left untouched.
|
||||
|
||||
(4) Regenerate the OpenAPI outputs; add alias `PermissionOption` to admin/src/api/types.ts.
|
||||
|
||||
(5) Fixture and smoke test. rosterPerson gains a nullable text column `permissions`; fields.yaml gains `permissions` (type permissioneditor, mode radio, tab, context update); the roster controller implements PermissionEditorProvider with four options in two tabs plus one without a tab, one of them locked for the limited admin, storing the map as a JSON object text in the column. `TestPermissionEditorSmoke`: the form schema carries the options with localized labels and the locked flag only for the limited admin; an update with allowed codes stores the JSON object and the show response returns it; an unknown code answers 422; a value of 2 answers 422; a non-object answers 422; changing the locked code as the limited admin answers 403 and the column is unchanged; a stored code that is not offered survives an update; a permissioneditor field without mode fails boot; a controller without the provider fails boot.
|
||||
|
||||
(6) SPA per UI-SPEC S5. New admin/src/components/form/fields/PermissionEditorField.vue: a container with the S5 border classes (danger border when invalid) and role group named by the field label; options grouped by tab into sections in the order the server sends, options without a tab in a last section labelled backend::lang.permissioneditor.other; each section header as in S5 (an h3; in checkbox mode the right side shows the column heading backend::lang.permissioneditor.allow); each permission row with label, optional comment and the control; radio mode: one Reka RadioGroupRoot per row styled as the three-segment control Allow (1), Inherit (0), Deny (-1) with the selected styles of the S5 table, a code missing from the value shown as Inherit; checkbox mode: one Reka CheckboxRoot per row with the list checkbox classes and the row label as its label; a locked option has a disabled control, a Lock icon after the label and the text backend::lang.permissioneditor.locked in place of the comment, and still shows its stored value; read-only (the preview, or attributes readonly true) disables every control and shows no lock icons; no options: the read-only box with backend::lang.permissioneditor.empty; below 640px the control wraps under the label; no inner scroll and no sticky headers. Emitted value: an object of code to integer; radio emits 1 or -1 and omits an inherited code; checkbox emits 1 and omits an unchecked code; codes that are not in the option list are never emitted; a locked row never changes. Any change marks the form dirty and clears the field's error. registry.ts: `permissioneditor` joins renderers and groupLabelledTypes and is not valueless. PreviewField.vue renders the type through this component with every control disabled. Phrase keys backend::lang.permissioneditor.allow, inherit, deny, locked, empty and other in en and pl with the UI-SPEC copy. Extend admin/tests/smoke/seams.smoke.test.ts: sections render by tab; choosing Deny on one row and saving sends that code with -1 and omits inherited codes; a locked row is disabled; an empty option list shows the empty text.
|
||||
|
||||
(7) Docs and READMEs in the same commit: modules/cabana/README.md (Features, API reference: PermissionOption, PermissionEditorProvider); docs/backend/forms.md new section "Permission editor" (the type, the two modes and their value sets, the provider's three methods with a `src=` fence from example_form_seams_test.go, locked options, kept unknown codes, the wire shape). Rebuild and commit modules/boardwalk/dist.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana/ && go test ./modules/cabana -run '^(TestPermissionEditor|TestPhase101|TestPhase10OpenAPIConformance|TestDatepicker|TestFileupload)' -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</automated>
|
||||
<fails_when>Any command exits non-zero; the named cabana run lacks "--- PASS: TestPermissionEditorSmoke" 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>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go doc ./modules/cabana PermissionEditorProvider` prints an interface with AdminPermissionOptions, AdminPermissionValues and AdminSetPermissionValues.
|
||||
- `go test ./modules/cabana -run '^TestPermissionEditorSmoke$' -count=1 -v` prints "--- PASS: TestPermissionEditorSmoke"; the test asserts 422 for an unknown code and for a value outside the mode's set, and 403 with an unchanged column for a changed locked code.
|
||||
- `grep -c "\['permissioneditor', PermissionEditorField\]" admin/src/components/form/registry.ts` prints 1.
|
||||
- `grep -c 'permissionOptions' admin/src/api/schema.d.ts` prints at least 1.
|
||||
- `grep -c 'permissioneditor:' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
- The Phase 10.1 widget tests pass unchanged (`go test ./modules/cabana -run '^TestPhase101' -count=1`), proving the widget fill contract was not widened.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin can put a tabbed permission editor on a form; the admin edits allow, deny and inherit (or allow) per permission, and the server stores only offered codes with allowed values.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: A foreign-key picker can be made writable, relation options can be locked for an admin and the server enforces the lock, list columns can be hidden but searchable, and filter choices can come from the controller</name>
|
||||
<reversibility rating="costly">D-27 G3, G4, G6 (user-confirmed): the relation field contract and RelationOption grow; `locked` reaches the generated TS types. The filter-options resolution adds no identifier.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/relation_field.go, modules/cabana/crud.go, modules/cabana/list_schema.go, modules/cabana/schema_types.go, modules/cabana/filter_schema.go, modules/cabana/query.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_form_test.go, modules/cabana/example_form_seams_test.go, modules/cabana/testdata/roster, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/components/form/fields/RelationField.vue, admin/src/components/form/PreviewField.vue, admin/src/components/list/DataTable.vue, admin/tests/smoke/seams.smoke.test.ts, admin/tests/fixtures/roster.form-schema.json, admin/tests/fixtures/roster.list-schema.json, modules/boardwalk/dist, docs/backend/relation-manager.md, docs/backend/lists-and-filters.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S6 "Locked options in RelationField"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (relation_field.go section), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("T-12-18: Every users_groups Write Path" path 1; gaps G3, G4, G6), modules/cabana/relation_field.go, modules/cabana/crud.go (save), modules/cabana/list_schema.go (columnDocument, compileColumns), modules/cabana/http.go (projectRow), modules/cabana/query.go (applyListSearch, normalizeSort), modules/cabana/filter_schema.go (validateFilter, filterOptions, filterProvider), modules/cabana/filter_options_test.go, modules/cabana/relation_field_test.go, admin/src/components/form/fields/RelationField.vue, admin/src/components/list/DataTable.vue, admin/tests/form/RelationField.test.ts, docs/backend/relation-manager.md, docs/backend/lists-and-filters.md</read_first>
|
||||
<action>Per D-27 (G3, G4, G6), D-07 and D-22; UI-SPEC S6. Repo: summercms.go; one code commit with generated outputs, README and docs.
|
||||
|
||||
(1) Writable foreign key (G3). relation_field.go: FieldRelationContract gains `WritableForeignKey bool` with a one-line doc comment ("declares a belongsTo foreign key that is a protected fill key writable through this relation field"); in compileFieldRelation the read-only decision becomes: read-only when the foreign key is a protected fill key and WritableForeignKey is false; setting it on a belongsToMany contract is the boot error "WritableForeignKey is only valid on belongsTo"; assignBelongsTo's own guard changes the same way, so the two never disagree. The protected fill key list is not edited, and a scalar field with a protected name stays unwritable. Submitted ids are still revalidated through the scoped options query.
|
||||
|
||||
(2) Locked options (G4). relation_field.go: `RelationLock` with IDs []uint (related ids the current admin may not add or remove) and Message string (a phrase key or text for the 403; may be empty); interface `RelationLockProvider` with AdminRelationLocks(ctx context.Context, field string) (RelationLock, error), documented as: asked per request with the request context (the principal is read with bouncer.User, the write transaction with TxFromContext when there is one); the lock is enforced on save, the flag on options is a display aid. RelationOption gains `Locked bool` with json key locked and omitempty, so responses without locks keep their bytes. RelationOptions marks locked rows; projectRelationFields marks locked labels on the show, create and update responses. New `checkRelationLocks(ctx, tx, cc, model, values)` called in save after checkRelationScope and before assignBelongsTo, on create and on update: for a belongsToMany value read the parent's current related ids from the pivot (none on create) and compare the locked subset before and after as sets; for a belongsTo value compare the current and the submitted id and refuse when they differ and either is locked; on a difference return a ForbiddenError whose Message is the lock's Message and whose Details name the field with that message (or with the phrase key backend::lang.form.forbidden when the lock gave none). Nothing is written before the check, and the surrounding transaction rolls back. A provider error is a lifecycle failure. A relation field absent from the body is not checked (nothing changes). A controller without the provider behaves exactly as before.
|
||||
|
||||
(3) Invisible columns (G6). list_schema.go: columnDocument gains yaml key `invisible`; schema_types.go ListColumn gains `Invisible bool` (json invisible, omitempty); http.go projectRow leaves invisible columns out of the row; search and sort treat the column as before (a searchable invisible column is searched). The SPA DataTable renders only columns that are not invisible; the first rendered column is the first visible one.
|
||||
|
||||
(4) Filter choices from the controller. filter_schema.go: the options handler and validateFilter ask the controller first when it implements pact.FilterOptions and fall back to the model; the scope itself is still the model's pact.FilterScope. pact/capabilities.go: the FilterOptions doc comment says so. No new identifier.
|
||||
|
||||
(5) Regenerate the OpenAPI outputs.
|
||||
|
||||
(6) Fixture and smoke tests. The roster model gains a nullable `organisation_id` (a protected fill key) pointing at a `rosterTeam` model, and a belongsToMany `tags` relation through a pivot model; fields.yaml gains `team` (type relation, belongsTo) and `tags` (type relation, belongsToMany); the controller sets WritableForeignKey on the team contract and implements RelationLockProvider locking the tag named "staff" for the limited admin with a phrase-key message; columns.yaml gains `email` with invisible true and searchable true; a filter `tagged` with a scope whose choices the controller serves from the database. `TestWritableForeignKeySmoke` (the team field is not read-only, a save sets organisation_id, an id outside the options scope answers 422, and the same contract without the flag compiles read-only), `TestRelationLockSmoke` (options and labels carry locked for the limited admin only; as the limited admin adding the locked tag on update answers 403 with details on tags and the pivot is unchanged; removing it answers 403; creating a record with it answers 403 and no row is created; changing only unlocked tags while keeping the locked one answers 200; the full admin may change it), `TestInvisibleColumnSmoke` (the schema flags the column, rows carry no email key, a search by email finds the row), `TestFilterOptionsController` (the options route returns the controller's choices; a model-only provider still works).
|
||||
|
||||
(7) SPA per UI-SPEC S6. RelationField.vue: in multiple mode a selected and locked chip has no remove button, shows a Lock icon in the 24px slot and carries visually hidden text from backend::lang.form.locked_item; Backspace in the empty search input removes the last unlocked chip or does nothing; in the listbox a locked, unselected option is aria-disabled, muted, shows a Lock icon at the right edge, has no hover highlight, is not chosen by click or Enter and is skipped by arrow keys; in single mode a locked current value renders the read-only box with a trailing Lock; when at least one locked option was seen, one note line with a Lock icon and backend::lang.form.locked_note shows under the control and its id joins aria-describedby. PreviewField keeps showing chips without remove buttons. Phrase keys form.locked_item and form.locked_note in en and pl with the UI-SPEC copy. Extend admin/tests/smoke/seams.smoke.test.ts: a locked chip has no remove button and survives Backspace; a locked option is not selectable; the note shows; an invisible column renders no header and no cell.
|
||||
|
||||
(8) Docs and READMEs in the same commit: modules/pact/README.md (the FilterOptions note); modules/cabana/README.md (API reference: WritableForeignKey, RelationLock, RelationLockProvider); docs/backend/relation-manager.md "Relation fields" (writable foreign keys, locked options and the 403, with `src=` fences from example_form_seams_test.go); docs/backend/lists-and-filters.md (the `invisible` column key; filter choices from the controller). Rebuild and commit modules/boardwalk/dist.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/cabana -run '^(TestWritableForeignKey|TestRelationLock|TestInvisibleColumn|TestFilterOptionsController|TestPhase10FilterOptions|TestPhase10Relation|TestPhase10OpenAPIConformance)' -count=1 -v && go test ./... -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 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...</automated>
|
||||
<fails_when>Any command exits non-zero; the named cabana run lacks any of "--- PASS: TestWritableForeignKeySmoke", "--- PASS: TestRelationLockSmoke", "--- PASS: TestInvisibleColumnSmoke", "--- PASS: TestFilterOptionsController", "--- PASS: TestPhase10RelationOptions", "--- PASS: TestPhase10RelationSave", "--- PASS: TestPhase10RelationForgedID", "--- PASS: TestPhase10RelationBoot" 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>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go doc ./modules/cabana RelationLockProvider` and `go doc ./modules/cabana RelationLock` each print a declaration, and `go doc ./modules/cabana FieldRelationContract` lists WritableForeignKey.
|
||||
- `go test ./modules/cabana -run '^TestRelationLockSmoke$' -count=1 -v` prints "--- PASS: TestRelationLockSmoke"; the test asserts 403 and an unchanged pivot for adding and for removing a locked id on update, 403 and no new row on create, and 200 for a change that leaves the locked subset alone.
|
||||
- `go test ./modules/cabana -run '^TestPhase10Relation' -count=1 -v` prints "--- PASS: TestPhase10RelationOptions", "--- PASS: TestPhase10RelationSave", "--- PASS: TestPhase10RelationForgedID" and "--- PASS: TestPhase10RelationBoot" and does not print "no tests to run": the existing relation field tests in modules/cabana/relation_field_test.go pass with their assertions unchanged, so relation responses keep their bytes when nothing is locked.
|
||||
- `grep -c 'locked?' admin/src/api/schema.d.ts` prints at least 1 and `grep -c 'invisible?' admin/src/api/schema.d.ts` prints at least 1.
|
||||
- `grep -c 'locked_note' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
- `go -C ../fonoteka.go build ./...` exits 0 (the application still compiles against the framework working tree).
|
||||
</acceptance_criteria>
|
||||
<done>A plugin can let admins pick a record for a protected foreign key, lock chosen relation options per admin with the server refusing any change to them, hide search-only columns, and serve filter choices from its database.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Decide how the framework contract of this phase is published as tag v0.1.3</name>
|
||||
<decision>Publish the framework work of plans 01 and 02 as the annotated tag v0.1.3 (D-25). The tag fixes the names and shapes listed below for every application that builds on it.</decision>
|
||||
<context>One-way door: once v0.1.3 is pushed, other projects may build on it, and renaming any of these would break their YAML, their Go code or their role rows. What the tag publishes: pact `AdminBulkAction`, `HasAdminBulkActions`, `AdminRecordAction`, `HasAdminRecordActions`, `RowState`, `ListRowStates`, `FormVirtualFields`, `FormRules`; cabana `ForbiddenError`, `BulkActionResult`, `RecordAction`, `VirtualFieldsFromContext`, `PermissionOption`, `PermissionEditorProvider`, `RelationLock`, `RelationLockProvider`, `FieldRelationContract.WritableForeignKey`, `FieldPreset`, `FormPreview`; YAML keys `bulkActions`, `recordActions`, `preview`, `invisible`, `preset`, field types `password` and `permissioneditor`, list messages `rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`, form messages `preview`, `edit`; routes `POST .../{controller}/bulk/{action}` and `POST .../{controller}/{id}/actions/{action}`; response fields `meta.row_states`, `meta.actions`, `locked`; the SPA route `.../{id}/preview`. One item was not in the confirmed list G1 to G7 and was found at planning: a controller may now serve scope-filter choices (`pact.FilterOptions` is asked on the controller before the model); it adds no identifier and is needed for the Users `groups` filter (D-23). Bulk and record actions have separate name spaces per kind (D-14 needs `activate` and `unban` in both). Undo cost after a push: a new tag (v0.1.4) and a migration note for every consumer; a local tag can still be deleted and recreated.</context>
|
||||
<options>
|
||||
<option id="tag-and-push">
|
||||
<name>The executor creates the annotated tag v0.1.3 on the green head and pushes master and the tag to origin</name>
|
||||
<pros>The plugin plans start from a published baseline; nothing is left pending.</pros>
|
||||
<cons>The contract is public at once; a later rename needs v0.1.4.</cons>
|
||||
</option>
|
||||
<option id="tag-local">
|
||||
<name>The executor creates the annotated tag locally and does not push; the user pushes later</name>
|
||||
<pros>Plans 03 to 05 can proceed (the application uses a local replace), and names can still be corrected by moving the local tag before it is pushed.</pros>
|
||||
<cons>The push stays a pending release step that someone must remember. While the tag is not on origin, the sm-user-plugin push at the end of plan 05 is skipped and recorded as pending too: a published plugin commit must not depend on an unpublished framework contract.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Do not tag now</name>
|
||||
<pros>Time to review or rename the contract first.</pros>
|
||||
<cons>Plan 03 has the tag as a precondition and stops until the tag exists.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md, .planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md (D-25, D-27), modules/cabana/README.md (API reference), modules/pact/README.md (API reference), docs/backend/forms.md, docs/backend/admin-controllers.md</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user answered with one of the option ids; the answer is recorded in the plan summary.
|
||||
- `git tag --list v0.1.3` prints nothing when the checkpoint is presented (nothing was tagged before the answer).
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Answer tag-and-push, tag-local or hold. With hold, say what should change before the tag.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: The framework head is proven green and tagged v0.1.3</name>
|
||||
<reversibility rating="one-way">A pushed v0.1.3 is a published contract: consumers pin it, and any later rename of a pact or cabana name, YAML key or route needs a new tag and a migration for each consumer.</reversibility>
|
||||
<precondition>The user answered tag-and-push or tag-local at Task 5; with hold this task is not run and the plan ends with the tag recorded as pending in the summary and in STATE.md.</precondition>
|
||||
<files>modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Pitfall 13 "Submodule and tag ordering"), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-05-SUMMARY.md (how v0.1.1 was released)</read_first>
|
||||
<action>Per D-25 and the answer of Task 5. Repo: summercms.go. No source change is expected in this task; if a gate below fails, fix the cause in its own code commit (with its generated outputs) and run every gate again before tagging.
|
||||
|
||||
(1) Confirm the working tree has no uncommitted code change (`git status --short` shows only planning files, if any) and record the head sha.
|
||||
|
||||
(2) Run every gate on that head: the framework full suite (go vet and go test over the whole module), the SPA typecheck and tests, the OpenAPI drift check, the dist check, the docs tree test and `summer docs:build --check`, the three contract tests, and the application's build, vet and full test suite against this tree (the application resolves the framework through its local replace).
|
||||
|
||||
(3) Create the annotated tag on the recorded head: name v0.1.3, message "SummerCMS v0.1.3: bulk and record actions, preview screen, row state, permission editor, form seams". Planning-doc commits made after this point do not move the tag.
|
||||
|
||||
(4) With the answer tag-and-push: push master and the tag to origin and confirm the remote lists the tag. With tag-local: do not push; record "push master and v0.1.3" as a pending release step in the summary and in STATE.md, and record next to it that the sm-user-plugin push of plan 05 Task 3 waits for it (that push runs only when `git ls-remote --tags origin v0.1.3` lists the tag).
|
||||
|
||||
(5) Record in the summary: the tagged sha, the option chosen, and the measured run time of each gate (for VALIDATION.md).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && 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 && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1 && test "$(git tag --list v0.1.3)" = "v0.1.3" && test "$(git cat-file -t v0.1.3)" = "tag" && git merge-base --is-ancestor v0.1.3 HEAD</automated>
|
||||
<fails_when>Any command exits non-zero; a 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"; `git tag --list v0.1.3` prints nothing; `git cat-file -t v0.1.3` prints "commit" (a lightweight tag).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git tag --list v0.1.3` prints v0.1.3, `git cat-file -t v0.1.3` prints tag, and `git rev-parse 'v0.1.3^{commit}'` equals the head sha recorded in step 1.
|
||||
- `scripts/check-admin-dist.sh` and `scripts/check-admin-openapi.sh --check` both succeed on the tagged commit.
|
||||
- With tag-and-push: `git ls-remote --tags origin v0.1.3` lists the tag. With tag-local: the summary and STATE.md name the pending push and state that the sm-user-plugin push of plan 05 waits for it.
|
||||
- `git tag --list 'v0.1.*'` still lists v0.1.1 and v0.1.2 unchanged.
|
||||
</acceptance_criteria>
|
||||
<done>The framework half of the phase is released as v0.1.3 (pushed, or local with the push recorded as pending), and the plugin plans have a fixed contract to build on.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser → admin API (save bodies) | Untrusted field values, virtual values, permission maps and relation ids cross here |
|
||||
| cabana → plugin hooks and providers | The framework hands hooks only validated values; providers decide options and locks per principal |
|
||||
| Plugin partial templates → admin browser | The status hint is server-rendered through the partial allowlist |
|
||||
| Repository → consumers of the tag | v0.1.3 publishes a contract other applications build on |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.1-09 | Tampering | mass assignment through virtual or password fields | high | mitigate | Virtual fields are never bound, filled or projected; their values reach hooks only through `VirtualFieldsFromContext` and only when the field's context allows the operation; the protected fill key list is unchanged (Task 2, TestVirtualFieldsSmoke). |
|
||||
| T-12.1-10 | Information Disclosure | password value in a response, log or URL | high | mitigate | `password` is never projected on any route; the SPA sends it only in the save body, clears it after a save and never writes it to a toast or the URL (Task 2, TestPasswordFieldSmoke). |
|
||||
| T-12.1-11 | Elevation of Privilege | protected foreign key written through a relation field | high | mitigate | Writable only with an explicit `WritableForeignKey` on the contract; ids are revalidated through the scoped options query; scalar fields with protected names stay unwritable (Task 4, TestWritableForeignKeySmoke). |
|
||||
| T-12.1-12 | Elevation of Privilege | locked relation ids changed by a crafted request (mechanism behind T-12-18) | high | mitigate | `checkRelationLocks` runs on create and update before any row write and returns `ForbiddenError`; the transaction rolls back; the SPA lock is display only (Task 4, TestRelationLockSmoke). |
|
||||
| T-12.1-13 | Tampering | permission code injection or out-of-range value | high | mitigate | Codes must be in the controller's option list, values in the mode's set, locked codes unchanged (403); stored codes that are not offered are kept, never invented (Task 3, TestPermissionEditorSmoke). |
|
||||
| T-12.1-14 | Tampering | a preview-only field written by a crafted body | medium | mitigate | Writes ask `contextAllows` only for create or update; TestPreviewSmoke posts the field and asserts the column unchanged (Task 1). |
|
||||
| T-12.1-15 | Elevation of Privilege | script or markup through the status hint or callout classes | medium | mitigate | The hint is rendered through the existing partial allowlist as nodes, never as an HTML string; the callout kit is plain CSS classes; server text is text (Task 1). |
|
||||
| T-12.1-16 | Tampering | open redirect or foreign route through `preview/:id` in plugin YAML | medium | mitigate | `mapWinterUrl` emits only the current controller's routes, digits only, and falls back to the list (Task 1, winterUrl cases in the smoke test). |
|
||||
| T-12.1-17 | Tampering | an unreviewed contract published by the tag | high | mitigate | Blocking decision checkpoint listing the published names (Task 5); the tag is created only on a head where every gate passed (Task 6). |
|
||||
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed or bumped by this plan: no dependency line of go.mod or admin/package.json is added or changed by its commits; any need for one stops at a blocking human checkpoint. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go vet ./... && go test ./... -count=1` green in summercms.go after every task commit; `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green before the tag.
|
||||
- `npm --prefix admin run typecheck && npm --prefix admin test` green; `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` clean after every task commit.
|
||||
- `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` pass.
|
||||
- `git tag --list v0.1.3` prints v0.1.3 and it is an annotated tag on a commit where all of the above passed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The preview screen, the permission editor and the seams G1 to G7 exist with the names in "Artifacts this phase produces" and behave as the D-11, D-16, D-27 and D-28 truths state.
|
||||
- The SPA matches UI-SPEC S2, S3, S5, S6 and S7.
|
||||
- READMEs, docs, the OpenAPI document, the TS types and `dist/` match the code at the tagged commit; no consuming application is named; no dependency changed.
|
||||
- v0.1.3 exists as an annotated tag (pushed or recorded as pending per the user's answer).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md` when done. Record the final contract names, the tag sha, the option chosen at Task 5 and the measured gate run times.
|
||||
</output>
|
||||
396
.planning/phases/12.1-user-plugin-admin-screens/12.1-03-PLAN.md
Normal file
396
.planning/phases/12.1-user-plugin-admin-screens/12.1-03-PLAN.md
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
phase: 12.1-user-plugin-admin-screens
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["12.1-02"]
|
||||
files_modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610040001_add_users_permissions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610040002_add_users_last_seen.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/permission_set.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/frontend_permission.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user/columns.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/privileged.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/permissions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/permissions_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/last_seen.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/request_db.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users/config_filter.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm
|
||||
- ../fonoteka.go/plugins/golem15/user/admin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_permissions.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_navigation.go
|
||||
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/views/mail/invite.htm
|
||||
- ../fonoteka.go/plugins/golem15/user/views/mail/invite-en.htm
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_harness_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_users_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/last_seen_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3]
|
||||
estimate:
|
||||
tokens: 270000
|
||||
raw_tokens: 270000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-26, this is plan 03 of five: the plugin foundation and the Users screen in sm-user-plugin (mounted at ../fonoteka.go/plugins/golem15/user), built on the framework tag v0.1.3; the groups field, the User Groups and Organisations screens and the submodule pointer bump are plan 04."
|
||||
- "Per D-01 and D-03, no impersonate action or button and no guest concept (no convert-guest action, no is_guest column, field or hint) exists in the Go admin screens; the seeded Guest group row is left as data."
|
||||
- "Per D-02 and D-04, the plugin registers the four PHP permission codes golem15.users.access_users, golem15.users.access_groups, golem15.users.access_settings and golem15.users.impersonate_user plus the new golem15.users.manage_privileged_groups, all under the tab golem15.user::lang.plugin.tab; the Users screen is reachable from the admin navigation item `user` (order 555) and gated by golem15.users.access_users."
|
||||
- "Per D-13, the Users list and form include deactivated (soft-deleted) users, `deactivate` is the soft delete, `restore` brings a user back, and `delete` (form button and bulk) permanently removes the user row together with its user_throttle rows, users_groups rows and attachments in one transaction, with blobs removed only after the commit and no typed confirmation."
|
||||
- "Per D-14, the Users list offers the bulk actions activate, deactivate, restore, ban and unban plus the built-in bulk delete, and the preview offers the record actions activate (when not activated), unban (when banned) and unsuspend (when suspended); ban and suspend state is read from and written to user_throttle, and none of these actions writes users_groups."
|
||||
- "Per D-12, Users rows carry the states deleted when deactivated, negative when banned and disabled when not activated, from one user_throttle query per page."
|
||||
- "Per D-11, a Users row opens the preview screen first; the preview shows one status callout by precedence banned, deactivated, not activated, suspended, the preview-only fields created_ip_address and last_ip_address, the applicable record actions and an edit button labelled from users.update_details; after create and after save-and-close the admin lands on the preview."
|
||||
- "Per D-15, the golem15_user_frontend_permissions table (code, label, tab, comment), the users.permissions column and a tolerant PermissionSet type exist; the user form's Permissions tab is a permissioneditor in radio mode (allow, deny, inherit) whose options come from that table; and classes.MergedPermissions plus classes.HasPermission reproduce PHP getMergedPermissions and hasPermission (groups merged with most positive wins, user values override, a permission is held only at exactly 1, wildcards on both sides). No application code calls the resolver yet."
|
||||
- "Per D-17 and D-29, users.last_seen is an additive column shown in the Users list and written by login and by token refresh at most once per five minutes in a single conditional UPDATE; a failed write is logged and never fails authentication; last_seen, permissions, created_at and updated_at never appear in any user API payload."
|
||||
- "Per D-18, there is no username column, list column or form field."
|
||||
- "Per D-19, the user form takes a password with confirmation on create (required) and on update (empty means unchanged), hashes it with bouncer.HashPassword at the configured cost, never returns it, stamps tokens_valid_after when an admin resets a password, and `send_invite` (on by default, create only) sends exactly one golem15.user::mail.invite message with an activation link after the create commits; created_ip_address and last_ip_address show on the preview only."
|
||||
- "Per D-29, a user created in the admin starts not activated and only the activate actions set is_activated; bulk activate skips users that are already active and the response reports the affected count."
|
||||
- "Per D-30 (threats T-12.1-38 and T-12.1-39), a user who is a member of a privileged group (D-05) is protected against takeover: for an admin without golem15.users.manage_privileged_groups, an update that changes that user's email or submits a password for that user, and a permanent delete of that user by the form button or by the bulk delete, is refused with 403 and changes nothing (no column, no token stamp, no pivot, throttle or attachment row; a bulk delete whose selection contains such a user deletes none of the selected users); with the permission each succeeds. This deliberately differs from PHP, where golem15.users.access_users alone is enough."
|
||||
- "Per D-30, no commit of the plugin has an unguarded write path: the guard and its helpers land in the commit that creates the Users controller (Task 1), before the permanent delete (Task 2) and the password field (Task 3) exist, so each of those is guarded from the commit that introduces it."
|
||||
- "Per D-05 and D-30, the privileged list is the config key golem15.user.privileged_groups (default [admin]) read per request by classes.PrivilegedGroupCodes; the key and classes/privileged.go are created by this plan and reused unchanged by plan 04."
|
||||
- "Per D-30 and D-14, the guard covers exactly email, password and permanent delete: a change of name, surname, organisation, avatar or frontend permissions of a privileged member, an update that submits the stored email unchanged and no password, and the actions activate, deactivate, restore, ban, unban and unsuspend still need only golem15.users.access_users."
|
||||
- "Per D-20, the user form's avatar is a fileupload field (mode image, 260 by 260) bound to the same system_files attachment (field avatar, the user's morph name) the user API writes, so an avatar set in the admin shows in the user API payload and one uploaded through the API shows in the admin."
|
||||
- "Per D-21, block_mail and MailBlocker are not ported; the todo .planning/todos/pending/mailblocker-with-mailing.md records them for the mailing work."
|
||||
- "Per D-22, the user form's `organisation` field is a belongsTo relation picker that writes organisation_id through the WritableForeignKey opt-in."
|
||||
- "Per D-23, the Users filters are groups (model scope filterByGroup with choices from user_groups), created date (daterange on created_at) and activated (switch on is_activated); no `conditions` key is used."
|
||||
- "Per D-08, User.Groups and every new model field stay out of the user API payloads: the application's user API parity flows and TestPhase12Threats pass unchanged."
|
||||
- "The migrations are additive only (two ALTER TABLE ADD COLUMN and one CREATE TABLE, each with a rollback), no shipped migration file is edited, the user API routes and payloads are unchanged, and no Go module is added."
|
||||
- "On the Users form a validation failure (for example a password confirmation mismatch or a duplicate email, including the email of a deactivated user) answers 422 and marks the field."
|
||||
- "UI P1 empty: The Users list with no rows shows `users.list_empty`; the empty-search state is the existing one."
|
||||
- "UI P1 populated: The Users list shows Name, Email, Registered and Last seen, with row-state badges Deactivated, Banned and Not activated, and rows open the preview."
|
||||
- "UI P1 zero-one-many: Users bulk confirms and success toasts address the selected users; counts come from the S1 `:count` rule."
|
||||
- "UI P2 empty: The Users create form opens with `send_invite` on and every other field empty; a user without an avatar shows the Phase 12.2 `fileupload` empty state."
|
||||
- "UI P2 partial: On the Users form `send_invite` shows on create only and the Permissions tab on update only."
|
||||
- "UI P2 error (D-30, UI-SPEC S6 forbidden save): A refused email or password change on a member of a privileged group saves nothing, keeps every entered value, shows the forbidden banner with `users.privileged_member_forbidden` and marks the refused fields (`email`, `password`) through the 403 details."
|
||||
- "UI P1 error (D-30, UI-SPEC S6 forbidden delete): A refused permanent delete of a member of a privileged group shows a danger toast with `users.privileged_member_delete_forbidden`, from the form's delete button and from the list's bulk delete alike; nothing is deleted."
|
||||
- "UI P3 loading: The Users preview loads as S3: skeleton hint on first fetch, previous hint kept on refetch."
|
||||
- "UI P3 error: The Users preview fails as S3, and its record actions fail as S2."
|
||||
- "UI P3 overflow: The Users preview shows at most one status callout, by precedence banned, deactivated, not activated, suspended; action buttons wrap in the footer."
|
||||
- "UI P3 long-text: Status callout text wraps and is never truncated."
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go"
|
||||
provides: "Users admin controller: scopes, row states, rules, virtual fields, bulk and record actions, hooks, partial data, relation and permission providers"
|
||||
contains: "golem15.user.users"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/admin_actions.go"
|
||||
provides: "ActivateUsers, DeactivateUsers, RestoreUsers, BanUsers, UnbanUsers, UnsuspendUser, ThrottleStates, ForceDeleteCleanup"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/privileged.go"
|
||||
provides: "PermissionManagePrivilegedGroups, PrivilegedGroupCodes, IsPrivilegedCode, PrivilegedGroupIDs, IsPrivilegedMember"
|
||||
contains: "IsPrivilegedMember"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/permissions.go"
|
||||
provides: "MergedPermissions, HasPermission, UserHasPermission, FrontendPermissionOptions"
|
||||
contains: "MergedPermissions"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/models/permission_set.go"
|
||||
provides: "PermissionSet with tolerant Scan and an object-of-integers Value"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go"
|
||||
provides: "golem15_user_frontend_permissions table"
|
||||
contains: "golem15_user_frontend_permissions"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/admin.go"
|
||||
provides: "AdminFS (explicit embed list), AdminControllers, capability assertions"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/admin_harness_test.go"
|
||||
provides: "plugin admin test harness: boot with cabana mounted, mint a backend principal with chosen permissions"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/plugins/golem15/user/plugin.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/admin.go"
|
||||
via: "the plugin implements pact.AdminAssets, pact.HasAdminControllers, pact.HasPermissions and pact.HasNavigation"
|
||||
pattern: "HasAdminControllers"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/admin_actions.go"
|
||||
via: "bulk and record action Run functions call the class functions with the transaction from cabana.TxFromContext"
|
||||
pattern: "classes\\.(ActivateUsers|BanUsers|UnsuspendUser)"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/privileged.go"
|
||||
via: "FormBeforeUpdate and FormBeforeDelete call classes.IsPrivilegedMember and cabana.Allows for PermissionManagePrivilegedGroups before a member's email, password or row is changed (D-30)"
|
||||
pattern: "IsPrivilegedMember"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/last_seen.go"
|
||||
via: "Login and Refresh call classes.TouchLastSeen and ignore its error"
|
||||
pattern: "TouchLastSeen"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/models/user.go"
|
||||
to: "system_files"
|
||||
via: "AttachRelations declares the relation named avatar the user API already writes"
|
||||
pattern: "AttachRelations"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): 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, a backend admin with the users permission opens Users from the navigation, filters and searches the list, sees who is deactivated, banned or not activated, opens a user's preview, activates, bans, unbans, unsuspends, deactivates, restores and permanently deletes users, creates a user with a password and an invitation, and edits name, email, organisation, avatar and frontend permissions.
|
||||
|
||||
<objective>
|
||||
Give `golem15.user` (repo `sm-user-plugin`) its admin foundation (three additive migrations, five permissions, navigation, the embedded admin YAML, a test harness) and port the PHP Users screen onto the framework contracts of v0.1.3: list with filters and row state, preview with status hint and record actions, form with password, invitation, organisation picker, avatar and the frontend permission editor, the delete semantics of PHP `Users.php`, the merged-permission resolver and the `last_seen` write.
|
||||
|
||||
Purpose: success criteria 1 and 3 for the Users screen; the groups field and its guard (success criterion 4) follow in plan 04 so `users_groups` never has an unguarded writer. The takeover guard of D-30 (a privileged-group member's email, password and permanent delete need `golem15.users.manage_privileged_groups`) lands here, in the commit that creates the Users controller, so no write path of this plan ever exists without it.
|
||||
Output: the files listed in the frontmatter, committed inside the plugin checkout.
|
||||
|
||||
Repos: every code change of this plan is committed inside the plugin checkout with `git -C ../fonoteka.go/plugins/golem15/user` (repo sm-user-plugin). Never stage plugin files from the application repository, and do not bump the submodule pointer or push (plan 04 bumps the pointer in a local commit; the plugin is pushed once, at the end of plan 05, after the security review and only when the framework tag v0.1.3 is on origin). Planning docs are committed separately in summercms.go. Never add co-author tags. The PHP reference at /media/nvme/dev/golem15/fonoteka is read-only. The plugin is shared across projects: migrations are additive, existing routes, payloads and exported functions keep their behaviour.
|
||||
|
||||
Known transitional state: the application's schema parity test (`TestSchemaMatchesPHPSnapshot`) reports the new table `golem15_user_frontend_permissions` until plan 04 adds its allow-list entry in the application repository. This plan therefore verifies the plugin's own suite, the application's user API parity flows and its admin boot tests, not the schema parity test.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
- Framework contracts at v0.1.3 (final names in the 12.1-01 and 12.1-02 summaries): `pact.AdminBulkAction`, `pact.HasAdminBulkActions`, `pact.AdminRecordAction`, `pact.HasAdminRecordActions`, `pact.RowState` with `RowStateDeleted`, `RowStateNegative`, `RowStateDisabled`, `pact.ListRowStates`, `pact.FormVirtualFields`, `pact.FormRules`, `pact.FilterScope`, `pact.FilterOptions` (controller first), `pact.AdminPartialData`, the Form hooks; `cabana.TxFromContext`, `cabana.VirtualFieldsFromContext`, `cabana.ValidationError`, `cabana.ForbiddenError`, `cabana.Allows`, `cabana.FieldRelationContract` (with `WritableForeignKey`), `cabana.FieldRelationProvider`, `cabana.PermissionOption`, `cabana.PermissionEditorProvider`; YAML keys `bulkActions`, `recordActions`, `preview`, `invisible`, `preset`, types `password`, `permissioneditor`, `fileupload`, list messages `rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`, form messages `preview`, `edit`.
|
||||
- Plugin today (`../fonoteka.go/plugins/golem15/user`, module `git.golem15.com/golem15/sm-user-plugin`, package `user`): `plugin.go` (`Plugin{app}`, interface assertion block, embeds `config`, `views/mail`, `lang`, `MailTemplates()`), `models.User` (soft delete through `DeletedAt`, `Groups` many2many with json "-", `MorphName()`, `Fillable()`, `Rules()` as the register rule set that must not change), `models.UserGroup`, `models.Organisation`, `models.Throttle`, `models.Register`; `classes.UserGroupCodes`, `classes.HasGroupCode`, `classes.IssueActivationCode`, `classes.VerifyActivationCode`, `classes.ContextWithThrottle`, `classes.RejectIfThrottled`, `classes.CheckAndRecordLogin`, `classes.LookupByEmail`; `controllers.Login`, `controllers.Refresh` (never loads the user row; the subject id comes from the token), `apiArray` (explicit payload map with `"permissions": nil` and `"groups": []`), `deleteAvatar` (attach.DeleteForOwner then attach.DeleteKeys), `activationLink`, `mailName`, `mailVars`, `sendTemplateVars`; `updates.Register`, `execStmts`; config namespace `golem15.user.*` (`password.bcrypt_cost`, `password.min_length`, `throttle.suspension_minutes`, `activation.activation_ttl_hours`).
|
||||
- Throttle rows are keyed by user and optional IP; login reads the row for the request IP, else the row with a NULL IP (`classes.RejectIfThrottled`). `user_throttle.user_id` references users(id) without ON DELETE.
|
||||
- Application analogs to copy: `../fonoteka.go/plugins/golem15/fonoteka/admin.go` (explicit `//go:embed` file list, `AdminFS`, `AdminControllers` with a lazy app lookup), `admin_permissions.go`, `admin_navigation.go`, `controllers/admin_registry.go`, `controllers/request_db.go`, `controllers/albums_admin_controller.go`, `controllers/collections_admin_controller.go`, `controllers/collections/*.yaml`, `models/collection/*.yaml`, `controllers/albums/_stats.htm`, `admin_tracer_test.go` (`assembleTracer`, `loginToken`, `getAuth`, `adminAPI`), `admin_collections_test.go` (minting a backend role and user with a chosen permission JSON).
|
||||
- PHP reference (read-only): `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user` (`controllers/Users.php`, `controllers/users/*`, `models/user/*.yaml`, `models/User.php`, `models/FrontendPermission.php`, `lang/en/lang.php`, `lang/pl/lang.php`, `views/mail/invite.htm`, `Plugin.php`) and `/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php` (`hasPermission`, `setPermissionsAttribute`) and `Throttle.php`.
|
||||
- Navigation icons available in the admin SPA at v0.1.3 (admin/src/app/icons.ts): `user`, `users`, `users-round`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- Migrations (gormigrate ids, one file each): `202610040001_add_users_permissions` (users.permissions TEXT NULL), `202610040002_add_users_last_seen` (users.last_seen TIMESTAMPTZ NULL), `202610040003_create_frontend_permissions` (table `golem15_user_frontend_permissions`: id, code unique, label, tab, comment, created_at, updated_at).
|
||||
- Models: `models.User` fields `Permissions PermissionSet`, `LastSeen *time.Time`, read-only `CreatedAt`, `UpdatedAt` (all json "-"), methods `AttachRelations()`, `FilterScopes()`, `FilterScope()`; `models.PermissionSet`; `models.FrontendPermission`.
|
||||
- Classes: `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates`, `ForceDeleteCleanup`, `MergedPermissions`, `HasPermission`, `UserHasPermission`, `FrontendPermissionOptions`, `TouchLastSeen`; in `classes/privileged.go` the constant `PermissionManagePrivilegedGroups` and `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs`, `IsPrivilegedMember` (plan 04 reuses them).
|
||||
- Config key `golem15.user.privileged_groups` (list of group codes, default `[admin]`).
|
||||
- Admin: controller id `golem15.user.users` (model name `Golem15\User\Models\User`, config dir `controllers/users`); `controllers.AdminControllers`; plugin methods `AdminFS`, `AdminControllers`, `Permissions`, `Navigation`; permission codes `golem15.users.access_users`, `golem15.users.access_groups`, `golem15.users.access_settings`, `golem15.users.impersonate_user`, `golem15.users.manage_privileged_groups`; navigation code `user` with side item `users`.
|
||||
- YAML: `controllers/users/config_list.yaml`, `config_form.yaml`, `config_filter.yaml`, `_hint.htm`; `models/user/fields.yaml`, `columns.yaml`.
|
||||
- Bulk actions `activate`, `deactivate`, `restore`, `ban`, `unban`; record actions `activate`, `unban`, `unsuspend`.
|
||||
- Mail templates `golem15.user::mail.invite`, `golem15.user::mail.invite-en`.
|
||||
- Lang keys under `golem15.user::lang.plugin.*`, `users.*`, `user.*`, plus `organisation.label` and `organisation.empty_organisation` (en and pl); among them the two D-30 messages `users.privileged_member_forbidden` and `users.privileged_member_delete_forbidden`.
|
||||
- Tests: `admin_harness_test.go`, `TestAdminUsersTracer`, `TestAdminPrivilegedMember`, `TestAdminUserActions`, `TestAdminUserForceDelete`, `TestAdminUserPassword`, `TestAdminUserInvite`, `TestAdminAvatarSharedWithAPI`, `TestMergedPermissions`, `TestPermissionSetScan`, `TestLastSeen`, migration tests in `updates/admin_columns_test.go`.
|
||||
|
||||
## Planner decisions recorded for this plan
|
||||
|
||||
- **Privileged-groups permission (D-04 discretion).** Code `golem15.users.manage_privileged_groups`, tab `golem15.user::lang.plugin.tab`, label `golem15.user::lang.plugin.manage_privileged_groups`. It is registered here and has no default role, so only a superuser or a role that was granted it explicitly holds it; the four PHP codes default to the `developer` role like the application's own permissions. This plan enforces it for the takeover paths of D-30; plan 04 enforces it for group membership and group codes.
|
||||
- **Takeover guard (D-30) and its ordering.** D-30 was decided after the plan check. The config key `golem15.user.privileged_groups` (D-05 discretion) and `classes/privileged.go` move from plan 04 into Task 1 of this plan, because Task 1's commit is the first that can write a user's email. The whole guard (email, password, delete) is written in that commit: the password half reads the form-only value, which does not exist until Task 3 declares the field, and the delete half sits in `FormBeforeDelete`, which guards the permanent delete from the moment Task 2 adds it. Plan 04 reuses the helpers unchanged.
|
||||
- **What D-30 covers.** Exactly the three operations D-30 names: email, password, permanent delete (form button and bulk delete). Everything else on a privileged member (name, surname, organisation, avatar, frontend permissions; activate, deactivate, restore, ban, unban, unsuspend) still needs only `golem15.users.access_users`, as in PHP (D-14): none of it hands the member's credentials to the acting admin, and each can be undone by another admin. The security review of plan 05 records this boundary next to the accepted risk T-12.1-26.
|
||||
- **Presentation of a refusal (D-30).** The email and password inputs are not shown locked: the framework at v0.1.3 has no per-record read-only state for text and password fields, and D-27 fixes the seam list. The refusal uses the forbidden presentation of UI-SPEC S6: the banner with marked fields on save, a danger toast on delete. The two message keys were added after the UI-SPEC sign-off; their texts are in Task 1 and follow the wording of `users.privileged_group_forbidden`.
|
||||
- **Navigation in two steps.** A navigation entry must point at a registered controller, so this plan registers the main item `user` with the side item `users` only; plan 04 adds `usergroups` and `organisations` together with their controllers.
|
||||
- **Ban semantics on Go throttle rows.** Ban sets `is_banned` on every `user_throttle` row of the user and makes sure a row with a NULL IP exists and is banned, so a login from any address is refused; unban clears the flag on every row; a user is banned when any row is banned; unsuspend clears `is_suspended`, `suspended_at` and `attempts` on every row; suspended is a pure read (a row with `is_suspended` and `suspended_at` inside `golem15.user.throttle.suspension_minutes`).
|
||||
- **Activate.** Sets `is_activated`, stamps `activated_at`, clears the activation code columns and restores the user when deactivated (as the Go `VerifyActivationCode` does); users already active are skipped.
|
||||
- **Bulk success copy.** A bulk action returns its fixed success phrase only when every selected user was affected; otherwise it returns no message and the affected count, so the framework's count toast is shown and never overstates (UI-SPEC checker note; D-29).
|
||||
- **Preview content.** No scoreboard (UI-SPEC: optional, left out).
|
||||
- **Password stamp.** An admin password reset stamps `tokens_valid_after`, as the user's own change-password path does, so existing tokens stop working.
|
||||
- **Spec-less probe fallback: skipped** (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" 1 to 6, 11 and 12 and the UI-SPEC rows lifted into `must_haves`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: An admin with the users permission opens Users from the navigation, filters and searches the list with row states, and edits a user's name and email</name>
|
||||
<reversibility rating="costly">D-04 and D-15 (user-confirmed): the permission codes are stored in backend roles of every host application, and the additive migrations ship in the shared user plugin.</reversibility>
|
||||
<precondition>The framework tag exists: `git tag --list v0.1.3` in summercms.go prints v0.1.3 (plan 02, D-25).</precondition>
|
||||
<files>../fonoteka.go/plugins/golem15/user/updates/202610040001_add_users_permissions.go, ../fonoteka.go/plugins/golem15/user/updates/202610040002_add_users_last_seen.go, ../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go, ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/models/user/columns.yaml, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/classes/privileged.go, ../fonoteka.go/plugins/golem15/user/classes/admin_actions.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/request_db.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_filter.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_permissions.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md (D-04, D-05, D-30), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory and Gap Map" Users list and Plugin registration; "Current State of sm-user-plugin"; Anti-Patterns; Pitfalls 1, 2, 11), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (the plugin sections: users_admin_controller.go, request_db.go and admin_registry.go, admin.go and permissions and navigation, plugin YAML, models, updates, plugin admin tests, "No Analog Found"; the classes section on privileged.go; "Hook reading the write transaction and the backend principal"), .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Navigation and permissions, Users list; S6 "Forbidden save"), ../fonoteka.go/plugins/golem15/user/config/config.yaml, modules/compass/config.go, modules/cabana/contracts.go (Allows), .planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md, .planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/throttle.go, ../fonoteka.go/plugins/golem15/user/models/registry.go, ../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go, ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go, ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/plugins/golem15/user/session_test.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_permissions.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/request_db.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_list.yaml, ../fonoteka.go/plugins/golem15/fonoteka/models/collection/columns.yaml, ../fonoteka.go/plugins/golem15/fonoteka/admin_tracer_test.go, ../fonoteka.go/plugins/golem15/fonoteka/admin_collections_test.go, modules/cabana/testdata/list/all_filters.yaml, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md (including "Refusing a write"), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/Users.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/config_list.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/config_filter.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/user/columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/lang/en/lang.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/lang/pl/lang.php</read_first>
|
||||
<action>Per D-02, D-03, D-04, D-05, D-12, D-13, D-15, D-17, D-18, D-23 and D-30. Repo: sm-user-plugin (commit with `git -C ../fonoteka.go/plugins/golem15/user`; one logical change per commit, for example migrations and model fields, then the admin registration with the controller, its YAML and the takeover guard of step (4a), then tests). The file classes/privileged.go and the guard go into the same commit that creates controllers/users_admin_controller.go, so the controller never exists without them.
|
||||
|
||||
(1) Migrations, one new file each, in the style of 202609220005_extend_users.go and 202610020001_create_user_groups.go (package-level slice, self-registration in init, rollback with IF EXISTS, a header comment naming the PHP source): id 202610040001_add_users_permissions adds users.permissions TEXT NULL; id 202610040002_add_users_last_seen adds users.last_seen TIMESTAMPTZ NULL; id 202610040003_create_frontend_permissions creates golem15_user_frontend_permissions with id SERIAL PRIMARY KEY, code VARCHAR(255) NOT NULL with a unique index, label VARCHAR(255) NOT NULL, tab VARCHAR(255) NOT NULL, comment VARCHAR(255) NULL, created_at and updated_at TIMESTAMPTZ NULL (PHP updates/v2.8.0/create_frontend_permissions_table.php). No shipped migration file is edited. updates/admin_columns_test.go: each migration applies and rolls back on a real Postgres through the existing harness.
|
||||
|
||||
(2) models/user.go: add `LastSeen *time.Time` (column last_seen), `CreatedAt time.Time` and `UpdatedAt time.Time` as read-only GORM fields (the arrow-only permission, so GORM never writes or stamps them) on columns created_at and updated_at; all three carry json "-". Add `FilterScopes()` returning the single name filterByGroup and `FilterScope(name, db, value)` narrowing users to members of the group id in value through a subquery on users_groups (an unknown name returns a query that matches nothing). Do not change Fillable(), Rules(), Hidden() or any existing field.
|
||||
|
||||
(3) Registration. admin_permissions.go: `Permissions()` with the five codes of the Artifacts section, tab constant golem15.user::lang.plugin.tab, labels golem15.user::lang.plugin.access_users, access_groups, access_settings, impersonate_user and manage_privileged_groups; the four PHP codes carry the default role developer; the privileged-groups code carries no role. admin_navigation.go: `Navigation()` with one item: code user, label golem15.user::lang.users.menu_label, icon user, permissions golem15.users.* (wildcard), order 555, controller golem15.user.users, side menu item users (label golem15.user::lang.users.menu_label, icon user, permission golem15.users.access_users, controller golem15.user.users). admin.go: an embed.FS with an explicit `//go:embed` list of every admin YAML and partial file (the controllers directory also holds a non-admin file, so no directory embed), `AdminFS()`, `AdminControllers()` delegating to controllers.AdminControllers with a closure that returns the plugin's app lazily, and the four compile-time assertions (pact.AdminAssets, pact.HasAdminControllers, pact.HasPermissions, pact.HasNavigation). controllers/admin_registry.go: `AdminControllers(app func() *backpack.App) []pact.AdminController` returning the users controller, plus the compile-time assertions of every optional interface the controller implements. controllers/request_db.go: the requestDB helper copied from the application analog (transaction from cabana.TxFromContext, else the pool).
|
||||
|
||||
(4) controllers/users_admin_controller.go: type usersAdminController holding the lazy app accessor; ID golem15.user.users; ModelName the PHP class string of the User model; ConfigDir controllers/users; RequiredPermissions golem15.users.access_users; NewRecord a new models.User. ListExtendQuery and FormExtendQuery both return the unscoped query (D-13 withTrashed). ListRowStates (pact.ListRowStates): one call of classes.ThrottleStates for the page's user ids, then per row RowStateDeleted when DeletedAt is set, RowStateNegative when banned, RowStateDisabled when not activated. FormRules (pact.FormRules): for create and update the email rule "required|between:6,255|email|unique:users" (password rules join in Task 3); the model's own Rules() stays the register contract. FilterOptions (pact.FilterOptions, on the controller): for scope filterByGroup the rows of user_groups ordered by name as value (the id as text) and label (the name); any other scope returns nothing. classes/admin_actions.go starts with `ThrottleStates(ctx, db, userIDs)` returning the banned and the suspended id sets per the ban semantics recorded above (a pure read).
|
||||
|
||||
(4a) Takeover guard (D-30, with the list of D-05). config/config.yaml: add the top-level key privileged_groups with the single default value admin (config path golem15.user.privileged_groups). classes/privileged.go, free functions in the style of classes/user_groups.go: constant PermissionManagePrivilegedGroups with the value golem15.users.manage_privileged_groups; `PrivilegedGroupCodes(cfg)` reading the list from the config on every call (a missing key or a nil config gives the single code admin; blank entries are dropped); `IsPrivilegedCode(codes, code)` taking the group's nullable code, comparing case-sensitively and returning false for a missing code; `PrivilegedGroupIDs(ctx, db, codes)` returning the ids of user_groups rows whose code is in the list; `IsPrivilegedMember(ctx, db, codes, userID)` reporting whether the user belongs to at least one group whose code is in the list, built on the existing classes.UserGroupCodes (a user in no group and a group without a code give false). Nothing is cached at boot. In the controller, one private helper serves both hooks: it answers nil when the principal on the context passes cabana.Allows for PermissionManagePrivilegedGroups, or when classes.IsPrivilegedMember (asked through requestDB, so inside the write transaction) is false; otherwise it answers a cabana.ForbiddenError with the message key and details it was given. A failed query is returned as it is and becomes the opaque 500. FormBeforeUpdate (pact.FormBeforeUpdate) begins with the guard, ahead of everything later tasks add to the hook: it reads the stored email of the user by id through requestDB with deactivated users included and compares it with the email on the model the hook receives, which is the value the save would write; it reads a submitted password from cabana.VirtualFieldsFromContext under the name password (no such form field exists until Task 3 declares it, so this half does nothing until then and guards the password from the commit that adds the field); when the email differs or a non-empty password was submitted it asks the helper with the message golem15.user::lang.users.privileged_member_forbidden and details that name email, password or both, each with a one-element list holding that message. FormBeforeDelete (pact.FormBeforeDelete) asks the helper with the message golem15.user::lang.users.privileged_member_delete_forbidden and no details; the framework calls this hook for the form's delete and once per record inside the single transaction of the bulk delete, so one refused record rolls the whole selection back. An update that changes neither value, a user who is not a privileged member and an admin holding the permission pass untouched; create is not guarded (a new user has no membership). Add the assertion lines for pact.FormBeforeUpdate and pact.FormBeforeDelete in admin_registry.go.
|
||||
|
||||
(5) YAML in the Go dialect (never copy PHP YAML verbatim: tabs, secondaryTabs, cssClass, hidden, disabled, valueFrom, a scalar toolbar.buttons and conditions are boot errors). controllers/users/config_list.yaml: list pointing at models/user/columns.yaml, the modelClass, title golem15.user::lang.users.list_title, recordUrl golem15/user/users/update/:id (Task 2 switches it to preview), noRecordsMessage, recordsPerPage 20, showCheckboxes true, showSetup true, filter config_filter.yaml, toolbar with an empty buttons list and the search prompt, and a messages block with empty golem15.user::lang.users.list_empty, rowStateDeleted golem15.user::lang.users.state_deactivated, rowStateNegative golem15.user::lang.users.state_banned, rowStateDisabled golem15.user::lang.users.state_not_activated. models/user/columns.yaml: id (invisible), name (searchable), surname (searchable, invisible), email (searchable), created_at (type datetime, label golem15.user::lang.user.created_at), last_seen (type datetime), created_ip_address and last_ip_address (searchable, invisible); no username and no is_guest column. controllers/users/config_filter.yaml: groups (label golem15.user::lang.group.label, modelClass of the UserGroup PHP class string, nameFrom name, scope filterByGroup), created_date (type daterange, column created_at), activated (type switch, column is_activated). controllers/users/config_form.yaml: name golem15.user::lang.user.label, form pointing at models/user/fields.yaml, the modelClass, defaultRedirect golem15/user/users, update redirect golem15/user/users/update/:id and redirectClose golem15/user/users, a messages block (update, saved, deleteConfirm golem15.user::lang.users.delete_confirm, deleted). models/user/fields.yaml: name, surname and email as type text under the tab golem15.user::lang.user.account (name and surname move into the Account tab; email span full).
|
||||
|
||||
(6) lang/en/lang.yaml and lang/pl/lang.yaml: add the keys this task names under plugin (tab, access_users, access_groups, access_settings, impersonate_user, manage_privileged_groups), users (menu_label, list_title, list_empty, state_deactivated, state_banned, state_not_activated, delete_confirm), user (label, id, name, surname, email, account, created_at, last_seen, created_ip_address, last_ip_address, status_activated) and group (label), with the PHP values from lang/en/lang.php and lang/pl/lang.php and the UI-SPEC texts for keys marked new or changed there. Keep the existing account keys. Two more keys under users for D-30 (new, decided after the UI-SPEC): privileged_member_forbidden with the text "This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved." and in Polish "Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane."; privileged_member_delete_forbidden with the text "Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted." and in Polish "Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte."
|
||||
|
||||
(7) plugin.go: no behaviour change; the new capabilities live in admin.go, admin_permissions.go and admin_navigation.go.
|
||||
|
||||
(8) Test harness admin_harness_test.go (package user): boot the plugin with cabana mounted on a real Postgres (the application's assembleTracer shows the recipe: application object, lagoon.Publish, party.Activate of golem15.user alone, lagoon.Migrate, surf.Assemble), helpers to insert a backend role with a chosen permission JSON and a backend user in it, log in and return the token, plus adminAPI path building and JSON request helpers. admin_users_test.go `TestAdminUsersTracer`: the navigation lists the item user for an admin holding golem15.users.access_users and not for an admin with no permission; that admin gets 403 on the Users list; the list returns active and deactivated users with meta.row_states (deleted for a deactivated user, negative for a banned one, disabled for a not activated one); a search by an invisible column (surname) finds the user and the row has no surname key; the groups filter narrows to a group's members and its options route lists the groups; the daterange and the activated filter work; an update of name and email answers 200 and persists; an update of a deactivated user answers 200 and keeps deleted_at; a duplicate email answers 422; no response body contains a password key. `TestAdminPrivilegedMember` (D-30) in the same file, with a group whose code is admin and an ordinary group created by the test and memberships inserted directly into users_groups (the groups field does not exist until plan 04): as an admin holding only golem15.users.access_users, an update that changes a privileged member's email answers 403 with the text of users.privileged_member_forbidden and details on email, and the stored email and name are unchanged although the same body also changed the name; an update of the same user that sends the stored email unchanged together with a new name answers 200 and the name persists; the form delete of that user answers 403 with the text of users.privileged_member_delete_forbidden and the row is still there with deleted_at unchanged; a member of the ordinary group only gets 200 for the same email change; a deactivated privileged member is protected the same way; with the config key overridden to the ordinary group's code the protection moves to that group's member; as an admin who also holds golem15.users.manage_privileged_groups the email change answers 200 and the delete succeeds.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/updates -run '^(TestAdminColumns|TestUserGroups)' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUsersTracer|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the tracer run lacks "--- PASS: TestAdminUsersTracer" or "--- PASS: TestAdminPrivilegedMember"; the updates run lacks a "--- PASS: TestAdminColumns" line; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUsersTracer$' -count=1 -v` prints "--- PASS: TestAdminUsersTracer"; the test asserts navigation gating, the three row states, the groups filter and its options, an update of a deactivated user, and a 422 on a duplicate email.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user ls-files updates | grep -c '2026100400'` prints 3.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- updates/00_base.go updates/10_organisations.go updates/202609220005_extend_users.go updates/202609220006_create_user_throttle.go updates/202609220007_create_jwt_blacklist.go updates/202610020001_create_user_groups.go` prints nothing (shipped migrations are untouched).
|
||||
- `grep -c 'golem15.users.manage_privileged_groups' ../fonoteka.go/plugins/golem15/user/admin_permissions.go` prints 1 and `grep -c 'impersonate_user' ../fonoteka.go/plugins/golem15/user/admin_permissions.go` prints at least 1.
|
||||
- TestAdminUsersTracer asserts, on the list schema served by the admin API, that the column keys are exactly id, name, surname, email, created_at, last_seen, created_ip_address and last_ip_address (so neither the dropped login-name column of D-18 nor the dropped guest column of D-03 exists) and that the filters are exactly groups, created_date and activated.
|
||||
- `go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1` passes (the user API payloads are unchanged).
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- go.mod` shows no added require line for a new module.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v` prints "--- PASS: TestAdminPrivilegedMember"; the test asserts, for an admin without golem15.users.manage_privileged_groups, 403 with an unchanged row for an email change and for the form delete of a privileged member, 200 for a name-only update of that member, and success for both refused requests once the admin holds the permission (D-30).
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- controllers/users_admin_controller.go` and `git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go` print the same commit sha, and the controller file as of that commit contains the guard: `git -C ../fonoteka.go/plugins/golem15/user grep -q 'IsPrivilegedMember' "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go)" -- controllers/users_admin_controller.go` exits 0.
|
||||
- `grep -c 'privileged_groups' ../fonoteka.go/plugins/golem15/user/config/config.yaml` prints 1 and `grep -c 'privileged_member_delete_forbidden' ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>The user plugin is an admin plugin: it registers permissions and navigation, its Users list with filters, search and row states works for an admin with the users permission, and a user's name and email can be edited; the email and the delete of a privileged-group member are refused without the privileged-groups permission.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: An admin opens a user's preview, sees the status hint, and activates, bans, unbans, unsuspends, deactivates, restores and permanently deletes users</name>
|
||||
<reversibility rating="reversible">Plugin controller code and YAML on top of the tagged framework contract; the delete is permanent by D-13 (user-confirmed) and guarded by the standard confirm.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/classes/admin_actions.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_users_test.go</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Users list bulk actions table, Users preview, the status hint and record action tables; "Partial style kit addition"), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" Users preview; "T-12-18" paths 2, 5 and 8; Pitfall 3; "Don't Hand-Roll"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (classes section: attachment cleanup on force delete; partial view model), ../fonoteka.go/plugins/golem15/user/classes/throttle.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (deleteAvatar, Login's restore of a deactivated user), ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums/_stats.htm, ../fonoteka.go/plugins/golem15/fonoteka/controllers/albums_admin_controller.go (PartialData), modules/cabana/testdata/roster/controllers/people/_status.htm, modules/cabana/testdata/roster/controllers/people/config_form.yaml, modules/lagoon/transaction.go (AfterCommit), modules/lagoon/attach/file.go (DeleteForOwner, DeleteKeys), docs/backend/admin-controllers.md (Bulk actions, Record actions), docs/backend/forms.md (Preview screen), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/Users.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/users/_hint_banned.htm, _hint_trashed.htm, _hint_activate.htm and _preview_toolbar.htm, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (attemptActivation, ban, unban, unsuspend, afterDelete), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/Throttle.php</read_first>
|
||||
<action>Per D-11, D-13, D-14 and D-30; UI-SPEC "Users list" and "Users preview". Repo: sm-user-plugin.
|
||||
|
||||
(1) classes/admin_actions.go, free functions taking the context and the transaction handle (never opening a second transaction): `ActivateUsers(ctx, db, users)` returning the number activated (skips users already active; sets is_activated, stamps activated_at, clears activation_code and activation_code_issued_at, clears deleted_at when set); `DeactivateUsers` (sets deleted_at on users that are not deactivated; returns the number changed); `RestoreUsers` (clears deleted_at on deactivated users); `BanUsers` and `UnbanUsers` per the ban semantics recorded above; `UnsuspendUser(ctx, db, userID)` (clears is_suspended, suspended_at and attempts on every row of the user); `ForceDeleteCleanup(ctx, db, bucket, user)` which deletes the user's user_throttle rows and users_groups rows, removes the user's attachments with attach.DeleteForOwner collecting the blob keys, and registers the blob deletion with lagoon.AfterCommit so blobs go only after the commit. None of these functions writes users_groups except the cleanup of a user being permanently deleted.
|
||||
|
||||
(2) Controller. AdminBulkActions (pact.HasAdminBulkActions): activate, deactivate, restore, ban and unban, each with label golem15.user::lang.users.NAME_selected, confirm golem15.user::lang.users.NAME_selected_confirm, permission golem15.users.access_users, and a Run that asserts every record is a user, calls the class function with the transaction from cabana.TxFromContext, returns the affected count, and returns the message golem15.user::lang.users.NAME_selected_success only when the affected count equals the number of records (otherwise an empty message). AdminRecordActions (pact.HasAdminRecordActions): activate (label golem15.user::lang.users.activate_user, confirm users.activate_confirm, success users.activated_success, applies when the user is not activated), unban (label users.unban_user, confirm users.unban_confirm, success users.unbanned_success, applies when banned) and unsuspend (label users.unsuspend_user, confirm users.unsuspend_confirm, success users.unsuspend_success, applies when suspended), each with permission golem15.users.access_users and an Applies that is a pure read. FormAfterDelete (pact.FormAfterDelete): call ForceDeleteCleanup and then delete the user row unscoped inside the same transaction, so the form delete button and the bulk delete are permanent (D-13). The FormBeforeDelete guard of Task 1 stays as it is and runs before this hook, so the permanent delete is guarded from the commit that introduces it (D-30): for an admin without golem15.users.manage_privileged_groups the delete of a privileged-group member is refused with 403, no cleanup runs and no blob deletion is registered, and a bulk delete whose selection contains such a user deletes none of the selected users. The bulk action deactivate is not a permanent delete and is not guarded. PartialData (pact.AdminPartialData) for the partial named hint: a curated view model with the callout kind (danger or warning) and the title and text phrase keys, chosen by precedence banned, then deactivated, then not activated, then suspended; an empty view model when none applies; an unknown partial name is an error. No impersonate action and no guest action exists.
|
||||
|
||||
(3) YAML. config_list.yaml: recordUrl becomes golem15/user/users/preview/:id; toolbar.buttons becomes [delete]; bulkActions [activate, deactivate, restore, ban, unban]; messages gain deleteConfirm golem15.user::lang.users.delete_selected_confirm and deleted. config_form.yaml: a preview block with headerPartial hint; recordActions [activate, unban, unsuspend]; messages edit golem15.user::lang.users.update_details; update.redirectClose golem15/user/users/preview/:id. models/user/fields.yaml gains created_ip_address and last_ip_address (type text, context preview, tab account, without the PHP disabled key). New controllers/users/_hint.htm: one callout using the summer-callout classes and role status, rendering nothing for an empty view model; add it and every changed YAML file to the embed list in admin.go.
|
||||
|
||||
(4) lang (en and pl), with the PHP values and the UI-SPEC texts for new and changed keys: users.activate_selected, deactivate_selected, restore_selected, ban_selected, unban_selected and their _confirm and _success keys; users.delete_selected_confirm; users.update_details; users.banned_hint_title and banned_hint_desc; users.trashed_hint_title and trashed_hint_desc (the PHP values copied verbatim); users.activate_warning_title and activate_warning_desc; users.suspended_hint_title and suspended_hint_desc; users.activate_user, activate_confirm, activated_success; users.unban_user, unban_confirm, unbanned_success; users.unsuspend_user, unsuspend_confirm, unsuspend_success.
|
||||
|
||||
(5) Tests in admin_users_test.go. `TestAdminUserActions`: bulk activate on two inactive and one active user answers affected 2 with no message and all three are active; bulk deactivate sets deleted_at and the users are still listed with the deleted state; bulk restore clears it; bulk ban makes a login attempt of that user fail and the row state negative; bulk unban allows login again; the record action activate is offered only for a not activated user and answers 409 on a second run; unban is offered only for a banned user; unsuspend is offered only for a user suspended inside the window and clears the suspension; the preview partial renders the banned callout before the deactivated one; the users_groups rows of every user are identical before and after each action. `TestAdminUserForceDelete`: the form delete and the bulk delete remove the user row, its user_throttle rows, its users_groups rows and its system_files rows for a user who has a throttle row, an ordinary group and an avatar, and the blob is deleted after the commit; an admin without golem15.users.access_users gets 403. Extend `TestAdminPrivilegedMember` (D-30) with the permanent delete: as an admin holding only golem15.users.access_users, the form delete of a privileged member who has a throttle row and an avatar answers 403 and the user row, its users_groups, user_throttle and system_files rows and its blob are all still there; a bulk delete of that member together with two ordinary users answers 403 with the text of users.privileged_member_delete_forbidden and all three users and their rows still exist; the bulk action deactivate on the member answers 200; as an admin who also holds golem15.users.manage_privileged_groups the form delete and the bulk delete remove the member with the full cleanup.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUserActions|TestAdminUserForceDelete|TestAdminUsersTracer|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestAdminUserActions", "--- PASS: TestAdminUserForceDelete" or "--- PASS: TestAdminPrivilegedMember"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserActions$' -count=1 -v` prints "--- PASS: TestAdminUserActions"; the test asserts the affected count of bulk activate with an already active user in the selection and that users_groups is unchanged after every action.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserForceDelete$' -count=1 -v` prints "--- PASS: TestAdminUserForceDelete"; the test deletes a user that has a user_throttle row (the foreign key without ON DELETE) and asserts no orphan row remains.
|
||||
- `grep -c 'preview/:id' ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml` prints 1.
|
||||
- TestAdminUserActions asserts that the list schema's bulk actions are exactly delete, activate, deactivate, restore, ban and unban and that the record actions the controller registers are exactly activate, unban and unsuspend (D-01 and D-03: nothing else is offered).
|
||||
- `grep -c 'summer-callout' ../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm` prints at least 1.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v` prints "--- PASS: TestAdminPrivilegedMember"; the test asserts that a bulk delete of one privileged member and two ordinary users by an admin without golem15.users.manage_privileged_groups answers 403 and leaves all three users, their throttle, pivot and attachment rows in place, and that the same bulk delete succeeds with the permission (D-30).
|
||||
</acceptance_criteria>
|
||||
<done>The Users preview, the status hint, the three record actions, the five bulk actions and the permanent delete behave as PHP Users.php does, on the framework's scoped action routes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: An admin creates a user with a password and an invitation, resets passwords, picks an organisation, sets an avatar shared with the app, and edits frontend permissions that a resolver can answer</name>
|
||||
<reversibility rating="costly">D-15 (user-confirmed): the stored permission JSON must stay readable for the cutover import, and the resolver's semantics become what other plugins rely on.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/permission_set.go, ../fonoteka.go/plugins/golem15/user/models/frontend_permission.go, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/classes/permissions.go, ../fonoteka.go/plugins/golem15/user/classes/permissions_test.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/views/mail/invite.htm, ../fonoteka.go/plugins/golem15/user/views/mail/invite-en.htm, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_users_test.go</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Users form), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" Users form; Pitfalls 1, 4, 5, 6, 12; "File upload (D-20)"; "Don't Hand-Roll"), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (models section; "No Analog Found" resolver note), ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (UploadAvatar, applyAvatar, ChangePassword, activationLink, mailName, mailVars, sendTemplateVars), ../fonoteka.go/plugins/golem15/user/controllers/registration.go, ../fonoteka.go/plugins/golem15/user/classes/codes.go, ../fonoteka.go/plugins/golem15/user/classes/mail.go, ../fonoteka.go/plugins/golem15/user/views/mail/activate.htm, ../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm, ../fonoteka.go/plugins/golem15/user/avatar_test.go, ../fonoteka.go/plugins/golem15/user/mail_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (captureMail), ../fonoteka.go/plugins/golem15/feedback/models/submission.go (AttachRelations), modules/cabana/contracts.go (granted, the wildcard reference), modules/cabana/example_form_seams_test.go, docs/backend/forms.md (password, form-only fields, permission editor, file uploads), docs/backend/relation-manager.md (writable foreign keys), modules/lagoon/validate.go (unique and soft-deleted rows), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/user/fields.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (getMergedPermissions, afterCreate, sendInvitation, rules), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/FrontendPermission.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/views/mail/invite.htm, /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php (hasAccess, hasPermission, setPermissionsAttribute)</read_first>
|
||||
<action>Per D-15, D-16, D-19, D-20, D-22, D-29 and D-30; UI-SPEC "Users form"; RESEARCH Pitfalls 1, 4, 5, 6 and 12. Repo: sm-user-plugin.
|
||||
|
||||
(1) Permission data. models/permission_set.go: `PermissionSet` (a map of code to integer) with a tolerant Scan (NULL, an empty string, the texts [] and null give an empty set; a JSON object gives its entries, accepting integers and numeric strings and skipping entries whose value is not numeric; any other content is an error) and a Value that writes NULL for an empty set and otherwise a JSON object of integers without zero values. models/user.go: add `Permissions PermissionSet` on column permissions with json "-". models/frontend_permission.go: `FrontendPermission` (ID, Code, Label, Tab, Comment as a nullable string, CreatedAt and UpdatedAt as nullable times; table golem15_user_frontend_permissions; registered in init). Rows come from migrations or seeds, as in PHP; no plugin-declared registry.
|
||||
|
||||
(2) Resolver in classes/permissions.go, a line-by-line port of PHP: `MergedPermissions(ctx, db, userID)` reads the permissions of the user's groups through users_groups (scanned tolerantly) and keeps per code the highest value across groups (most positive wins), then overlays the user's own values (the user's value replaces the group's); `HasPermission(merged, code)` is true only when the matching value is exactly 1 and supports a trailing or leading asterisk wildcard on the asked code and on the stored code, as Winter's hasPermission does; `UserHasPermission(ctx, db, userID, code)` combines the two; `FrontendPermissionOptions(ctx, db)` returns the table's rows ordered by tab then code. Nothing in the application calls the resolver yet.
|
||||
|
||||
(3) Form. models/user/fields.yaml (tab account unless said otherwise): send_invite (type checkbox, default true, context create, label and comment from user.send_invite and user.send_invite_comment); password (type password, span left, context create and update, label user.password, comment user.password_comment); password_confirmation (type password, span right, context create and update, label user.confirm_password, comment user.confirm_password_comment); organisation (type relation, nameFrom name, span full, emptyOption golem15.user::lang.organisation.empty_organisation, label golem15.user::lang.organisation.label); avatar (type fileupload, mode image, imageWidth 260, imageHeight 260, label user.avatar); permissions (type permissioneditor, mode radio, span full, context update, tab golem15.user::lang.user.permissions_tab). No username, block_mail, groups (plan 04) or guest field. config_list.yaml toolbar.buttons becomes [create, delete] and messages gain create golem15.user::lang.users.new_user. config_form.yaml: create.redirect golem15/user/users/preview/:id, create.redirectClose golem15/user/users, messages create.
|
||||
|
||||
(4) Controller. FormVirtualFields: password, password_confirmation, send_invite. FormRules: on create the password rule is required, between the configured minimum length (golem15.user.password.min_length, default 8) and 255, confirmed; on update it is nullable with the same bounds and confirmed; the email rule stays. The takeover guard of Task 1 stays the first statement of FormBeforeUpdate and is not changed: now that password is a declared form-only field, a non-empty password submitted for a privileged-group member by an admin without golem15.users.manage_privileged_groups is refused with 403 and details on password before the uniqueness check, the hashing and the token stamp below run (D-30); everything this step adds to the hook goes after the guard. FormBeforeCreate and FormBeforeUpdate: refuse an email that another user, deactivated ones included, already has with a ValidationError on email (the unique rule ignores deactivated rows); when a password was submitted (read from cabana.VirtualFieldsFromContext) store its hash from bouncer.HashPassword at the configured cost (golem15.user.password.bcrypt_cost, default 10); on update a submitted password also stamps tokens_valid_after with the current time; an absent or empty password on update changes nothing. A user created in the admin keeps is_activated false (D-29). FormAfterCreate: when send_invite is true, issue an activation code with classes.IssueActivationCode inside the transaction and register with lagoon.AfterCommit the sending of exactly one mail from the template golem15.user::mail.invite (the locale variant chosen as the other user mails do) carrying the user's name and the activation link built by the existing activationLink; a create that rolls back sends nothing. AdminFieldRelations (cabana.FieldRelationProvider): organisation as belongsTo to models.Organisation with foreign key organisation_id, label column name and WritableForeignKey true. PermissionEditorProvider for the field permissions: options from FrontendPermissionOptions (code, label, tab, comment; none locked), values from the user's Permissions, and the setter assigning the validated map to it. models/user.go: `AttachRelations()` declaring the relation named avatar with the same public flag the user API uses when it stores an avatar.
|
||||
|
||||
(5) Mail. views/mail/invite.htm and invite-en.htm ported from the PHP invite template in the structure of the existing activate templates; plugin.go MailTemplates() gains golem15.user::mail.invite and golem15.user::mail.invite-en.
|
||||
|
||||
(6) lang (en and pl): users.new_user; user.password and user.password_comment (new, UI-SPEC texts); user.confirm_password and its comment; user.send_invite and its comment; user.avatar; user.permissions_tab (new); organisation.label and organisation.empty_organisation; the invite mail's subject key if the template uses one.
|
||||
|
||||
(7) Tests. classes/permissions_test.go: `TestMergedPermissions` as a table of cases (two groups with different values for one code keep the higher; a user value of -1 overrides a group's 1; a user value of 1 overrides a group's -1; a code held only at 0 or -1 is not held; trailing and leading wildcards on either side; a user with no groups) and `TestPermissionSetScan` (NULL, empty string, the text [], an object with numeric strings, an object with a non-numeric entry, malformed JSON; Value for an empty set and for a set with a zero). admin_users_test.go: `TestAdminUserPassword` (create with matching passwords answers 201, the stored hash verifies with bouncer.CheckPassword, the user is not activated, and no create, show, update or list response contains the password or a password key; a confirmation mismatch answers 422 on password; an update without a password keeps the hash and tokens_valid_after; an update with a new password changes the hash and stamps tokens_valid_after; a create with the email of a deactivated user answers 422 on email), `TestAdminUserInvite` (create with send_invite true sends exactly one invite mail whose link activates the user through the existing activation route; with send_invite false no mail; a create that fails validation sends none), `TestAdminAvatarSharedWithAPI` (an avatar uploaded through the admin file route and saved appears as avatar_url and has_avatar in the user API payload; an avatar uploaded through the user API appears in the admin file list of the avatar field), plus cases in an existing or new test for the organisation picker (a save sets and clears organisation_id) and the permission editor (the form schema offers the seeded permissions grouped by tab; saving allow and deny stores the JSON object; an unknown code answers 422; the user API payload still carries permissions as null). Extend `TestAdminPrivilegedMember` (D-30) with the password: as an admin holding only golem15.users.access_users, an update that submits a password and its confirmation for a privileged member answers 403 with the text of users.privileged_member_forbidden and details on password, the stored hash and tokens_valid_after are unchanged, a login with the old password still succeeds and a login with the attempted password fails; a body that changes the email and submits a password names both fields in the details; a body with an empty password and a new name answers 200; changing the avatar, the organisation and the frontend permissions of that member answers 200; as an admin who also holds golem15.users.manage_privileged_groups the password reset answers 200 and stamps tokens_valid_after.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^(TestMergedPermissions|TestPermissionSetScan)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUserPassword|TestAdminUserInvite|TestAdminAvatarSharedWithAPI|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; a named run lacks any of "--- PASS: TestMergedPermissions", "--- PASS: TestPermissionSetScan", "--- PASS: TestAdminUserPassword", "--- PASS: TestAdminUserInvite", "--- PASS: TestAdminAvatarSharedWithAPI", "--- PASS: TestAdminPrivilegedMember"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^TestMergedPermissions$' -count=1 -v` prints "--- PASS: TestMergedPermissions"; its table includes a case where a user-level 1 overrides a group-level -1 (the port is not "deny wins").
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserPassword$' -count=1 -v` prints "--- PASS: TestAdminUserPassword"; the test asserts that an update changing only the name answers 200 (RESEARCH Pitfall 1) and that no response contains the password.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserInvite$' -count=1 -v` prints "--- PASS: TestAdminUserInvite"; the test asserts exactly one mail.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminAvatarSharedWithAPI$' -count=1 -v` prints "--- PASS: TestAdminAvatarSharedWithAPI".
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v` prints "--- PASS: TestAdminPrivilegedMember"; the test asserts that a password submitted for a privileged member by an admin without golem15.users.manage_privileged_groups answers 403 with an unchanged hash and an unchanged tokens_valid_after, that the old password still logs in, and that the same reset answers 200 with the permission (D-30).
|
||||
- `grep -c 'imageWidth: 260' ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml` prints 1.
|
||||
- `grep -c 'golem15.user::mail.invite' ../fonoteka.go/plugins/golem15/user/plugin.go` prints 2.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user diff 8a65890 -- models/user.go` shows no change inside the Rules(), Fillable() and Hidden() methods.
|
||||
- `go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1` passes.
|
||||
</acceptance_criteria>
|
||||
<done>The Users form carries the PHP create and update behaviour (password, invitation, organisation, avatar, frontend permissions), and any plugin can ask the resolver whether a user holds a frontend permission.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The Users list shows when each user was last seen, written by login and refresh without ever failing them, and the plugin README describes the admin screens</name>
|
||||
<reversibility rating="reversible">One guarded UPDATE in two handlers and documentation; the column itself was added in Task 1.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/classes/last_seen.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go, ../fonoteka.go/plugins/golem15/user/last_seen_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md (Open Question 4, resolved by D-29; Pitfall 11; "Current State of sm-user-plugin" auth path), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login, Refresh, apiArray), ../fonoteka.go/plugins/golem15/user/session_test.go, ../fonoteka.go/plugins/golem15/user/README.md, modules/bouncer (Refresh and VerifyClaims signatures, through `go doc ./modules/bouncer Refresh` and `go doc ./modules/bouncer VerifyClaims`)</read_first>
|
||||
<action>Per D-17 and D-29. Repo: sm-user-plugin.
|
||||
|
||||
(1) classes/last_seen.go: `TouchLastSeen(ctx, db, userID)` runs one UPDATE on users that sets last_seen to the current time for that id only when last_seen is NULL or older than five minutes, and returns the error to the caller without logging secrets. It does not touch updated_at and applies to deactivated users too.
|
||||
|
||||
(2) controllers/api_controller.go: Login calls TouchLastSeen after the successful credential and throttle checks and before the response; Refresh calls it after a successful refresh with the subject id read from the new token's claims (Refresh never loads the user row). In both, an error is logged at warning level with the user id and the handler continues: the response, its status and its body are exactly what they are today. No payload gains a key.
|
||||
|
||||
(3) last_seen_test.go `TestLastSeen`: a login sets last_seen; a second login within five minutes leaves the value unchanged; with last_seen moved six minutes into the past a refresh updates it; with the users table made unwritable for that statement (for example a database handle whose update fails) login and refresh still answer 200 with the same body shape; the login, fetch and refresh payloads contain none of the keys last_seen, created_at, updated_at, and their permissions key is still null and groups still an empty list; marshalling a models.User with encoding/json yields no last_seen, permissions, created_at or updated_at key.
|
||||
|
||||
(4) README.md of the plugin (the standard structure, no consuming-application name): the admin screens (Users now; User Groups and Organisations announced as part of the same release and filled in by plan 04), the five permission codes, the navigation item, the three migrations, the frontend permission resolver (MergedPermissions, HasPermission, UserHasPermission), the last_seen rule, and the protection of privileged-group members (D-30): the config key golem15.user.privileged_groups with its default, and the rule that changing such a user's email or password or permanently deleting them needs golem15.users.manage_privileged_groups on top of golem15.users.access_users.
|
||||
|
||||
(5) Run the plugin's full suite and the application checks named in the verify block; record the measured run times in the summary for VALIDATION.md.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestLastSeen|TestLogin|TestRefresh)' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestLastSeen"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestLastSeen$' -count=1 -v` prints "--- PASS: TestLastSeen"; the test asserts the five-minute rule, that a failing write leaves login and refresh at 200, and that no user payload carries last_seen.
|
||||
- `grep -c 'TouchLastSeen' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go` prints 2.
|
||||
- `go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1` passes (payloads byte-identical).
|
||||
- `grep -c 'manage_privileged_groups' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1 and `grep -ci 'fonoteka\|płytarium\|plytarium' ../fonoteka.go/plugins/golem15/user/README.md` prints 0.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user status --short` prints nothing after the task's commits.
|
||||
</acceptance_criteria>
|
||||
<done>last_seen is recorded on login and refresh under the five-minute rule without any effect on authentication or payloads, the Users list shows it, and the plugin README documents the admin half.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Backend admin → admin API (Users controller) | Field values, virtual values, ids and action names from an authenticated admin |
|
||||
| Admin screens → frontend user accounts | Admin actions change who can log in (activate, ban, password, delete) |
|
||||
| Admin with the users permission → accounts of privileged-group members | Whoever controls such an account's email or password holds its site-admin powers (D-30) |
|
||||
| User plugin → user API payloads | New model fields must not reach the Nuxt contract |
|
||||
| Plugin → mail transport | An admin-triggered invitation leaves the system |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.1-18 | Elevation of Privilege | Users screens and actions without the users permission | high | mitigate | RequiredPermissions golem15.users.access_users on the controller and on every bulk and record action; navigation filtered per principal; 403 asserted for an admin without it (Tasks 1, 2). |
|
||||
| T-12.1-19 | Information Disclosure | password hash, plain password or reset codes in admin responses | high | mitigate | No form field maps to the hash; password is a virtual `password` field that the framework never projects; model secrets keep json "-"; tests scan every response body (Tasks 1, 3). |
|
||||
| T-12.1-20 | Tampering | mass assignment of is_activated, permissions or organisation_id through the user form | high | mitigate | The framework's protected fill keys stay; activation only through the activate actions (D-29); permissions only through the permission editor's validated path; organisation_id only through the relation field with the explicit opt-in and scoped ids (Task 3). |
|
||||
| T-12.1-21 | Spoofing | tokens issued before an admin password reset stay valid | medium | mitigate | A password submitted on update stamps tokens_valid_after, as ChangePassword does (Task 3, TestAdminUserPassword). |
|
||||
| T-12.1-22 | Denial of Service | invitation mail abuse | low | mitigate | send_invite is create-only and sends one mail per created user after the commit; there is no bulk invite action (Task 3, TestAdminUserInvite). |
|
||||
| T-12.1-23 | Tampering | permanent delete leaves orphans or half-deleted users | medium | mitigate | ForceDeleteCleanup and the unscoped delete run in the save transaction; blobs are deleted after the commit; the standard confirm states that the delete is permanent (Task 2, TestAdminUserForceDelete). |
|
||||
| T-12.1-24 | Information Disclosure | last_seen, permissions, timestamps or groups leak into user API payloads | high | mitigate | json "-" on every new field; payloads come from the explicit apiArray map; TestLastSeen, TestUserAPINuxtFlows, TestParityCorpus and TestPhase12Threats (Tasks 1, 3, 4). |
|
||||
| T-12.1-25 | Denial of Service | last_seen write fails or amplifies writes on the auth path | low | mitigate | One conditional UPDATE at most every five minutes per user; errors are logged and ignored (Task 4). |
|
||||
| T-12.1-26 | Elevation of Privilege | a deactivated site admin is re-enabled by restore or activate | medium | accept | PHP behaves the same (the membership survives a soft delete) and the action needs golem15.users.access_users; recorded for the security review of plan 05 (RESEARCH T-12-18 path 8). |
|
||||
| T-12.1-27 | Elevation of Privilege | a wrong port of the permission merge grants a frontend permission | medium | mitigate | Line-by-line port of getMergedPermissions and hasPermission with a table test covering overrides, the exactly-1 rule and wildcards; no caller exists yet (Task 3, TestMergedPermissions). |
|
||||
| T-12.1-38 | Elevation of Privilege | takeover of a privileged-group member's account: an admin holding only golem15.users.access_users resets that user's password, or changes their email and then uses the password-reset mail, and logs in as a site admin | critical | mitigate | D-30 (user decision at the plan check; differs from PHP on purpose). The guard at the top of the Users controller's FormBeforeUpdate refuses a changed email or a submitted password on a privileged member with `cabana.ForbiddenError` (403, rollback, nothing saved) unless the principal passes `cabana.Allows` for golem15.users.manage_privileged_groups; membership is read inside the write transaction against the per-request list of D-05; the guard exists from the commit that creates the controller (Tasks 1 and 3, TestAdminPrivilegedMember; removal test in plan 05). |
|
||||
| T-12.1-39 | Tampering | permanent delete of a privileged-group member, by the form button or inside a bulk delete, by an admin holding only golem15.users.access_users | high | mitigate | D-30. FormBeforeDelete refuses with `cabana.ForbiddenError` before the row, its cleanup or any blob deletion is touched; the bulk delete runs in one transaction, so a selection containing one such user is refused as a whole (Tasks 1 and 2, TestAdminPrivilegedMember; removal test in plan 05). Outside D-30 by its wording and left at golem15.users.access_users as in PHP (D-14): deactivate, restore, ban, unban, unsuspend and activate on a privileged member; none of them gives the acting admin the member's credentials, and T-12.1-26 records the related accepted risk. |
|
||||
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed: the plugin's go.mod gains no require line (acceptance check in Task 1); any need for a module stops at a blocking human checkpoint. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1` green after every commit in the plugin checkout.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v` green after Tasks 1, 2 and 3 (D-30: email, permanent delete, password).
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1` green (the application still boots its admin with the plugin's controllers; T-12-18's payload rule holds).
|
||||
- `go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1` green (user API contract unchanged).
|
||||
- Not expected green until plan 04: `go -C ../fonoteka.go test ./parity -run TestSchemaMatchesPHPSnapshot` (the new table needs its allow-list entry).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Users has its list (columns, search, the three PHP filters, row states), preview (hint, preview-only fields, record actions) and create and update form (password, invitation, organisation, avatar, permissions), reachable from the navigation and gated by backend permissions.
|
||||
- activate, unban, unsuspend, delete and the bulk actions behave as PHP Users.php, with the deviations recorded in D-29.
|
||||
- The frontend permission data and resolver exist and are proven by unit tests; last_seen is written under the five-minute rule.
|
||||
- A privileged-group member's email, password and permanent delete need `golem15.users.manage_privileged_groups`; without it the request is refused with 403 and nothing changes, a bulk delete as a whole (D-30).
|
||||
- The user API payloads are byte-identical; migrations are additive; no dependency was added.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md` when done. Record the plugin commit shas, the harness helper names (plan 04 and 05 reuse them) and the measured run times.
|
||||
</output>
|
||||
348
.planning/phases/12.1-user-plugin-admin-screens/12.1-04-PLAN.md
Normal file
348
.planning/phases/12.1-user-plugin-admin-screens/12.1-04-PLAN.md
Normal file
@@ -0,0 +1,348 @@
|
||||
---
|
||||
phase: 12.1-user-plugin-admin-screens
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["12.1-03"]
|
||||
files_modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/models/slug.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/users_group.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user_group.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/organisation.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/permission_set.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/models/usergroup/columns.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/models/organisation/columns.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_list.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_form.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_list.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_form.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/admin.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_navigation.go
|
||||
- ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/README.md
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_groups_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_organisations_test.go
|
||||
- ../fonoteka.go/parity/schema_diff_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-4]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-26, this is plan 04 of five: the groups field with its guard, the User Groups and Organisations screens in sm-user-plugin, and in fonoteka.go the parity allow-list entry and the submodule pointer on a framework that contains v0.1.3; plan 05 adds the full unit tests, the gate and the security review, and publishes the plugin after that review."
|
||||
- "Per D-04 (threat T-12-18 revisited), golem15.users.access_users is enough to change a user's membership of ordinary groups, and adding or removing a privileged group additionally requires golem15.users.manage_privileged_groups; the check runs on the server for the only path that writes users_groups, the user form's groups field, on create and on update."
|
||||
- "Per D-05, the privileged groups are the list of group codes at the config key golem15.user.privileged_groups, default [admin], read per request; codes compare case-sensitively and a group without a code is never privileged. The key and classes/privileged.go exist since plan 03 Task 1 (D-30 needed them there); this plan reuses them and changes neither file."
|
||||
- "Per D-30, the takeover guard of plan 03 follows the membership this plan makes editable: once an admin holding golem15.users.manage_privileged_groups gives a user a privileged group through the groups field, an admin without that permission can no longer change that user's email or password or permanently delete them, and the protection ends when the membership is removed."
|
||||
- "Per D-06, the User Groups screen is gated by golem15.users.access_groups; creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group each require golem15.users.manage_privileged_groups and are otherwise refused with 403 and change nothing; ordinary groups are created, edited and deleted freely."
|
||||
- "Per D-07, for an admin without the extra permission the privileged groups appear in the user form's groups field as locked options and labels, and a save that would change a privileged membership anyway is refused with 403, the message users.privileged_group_forbidden and details on groups, with no partial save (no column and no pivot row changes)."
|
||||
- "Per D-08, User.Groups stays out of every user API payload: only the admin API reads or writes group membership, and the application's TestPhase12Threats and user API parity flows pass unchanged."
|
||||
- "The user form's groups field is the single writer of users_groups besides the cleanup of a permanently deleted user or group: there is no groups relation manager, and no bulk action, record action or organisation route changes a membership."
|
||||
- "Per D-15 and D-16, the group form's Permissions tab is a permissioneditor in checkbox mode whose options come from golem15_user_frontend_permissions and whose values are stored in user_groups.permissions as a JSON object of code to 1."
|
||||
- "Per D-22 and D-20, Organisations has a list and a form (name, slug preset from name with type slug, description, avatar as a fileupload image of 120 by 120) gated by golem15.users.access_users, and a `members` relation manager (hasMany, link and unlink) that sets and clears the user's organisation_id."
|
||||
- "Per D-24, the User Groups list shows the users_count column (not sortable), filled by one query."
|
||||
- "Per D-29, a User Group can be deleted with the standard form delete; the delete is guarded per D-06 and removes the group's users_groups rows in the same transaction."
|
||||
- "Per D-25, the application repository records the plugin checkout's head in one local commit, and the framework tree it builds against contains the tag v0.1.3; the application's full test suite passes, including TestSchemaMatchesPHPSnapshot with one new allow-list entry for golem15_user_frontend_permissions."
|
||||
- "This plan publishes nothing: neither sm-user-plugin nor fonoteka.go is pushed. The plugin is pushed once, in plan 05 Task 3, after the security review and only when the framework tag v0.1.3 is on origin, so a published plugin commit never depends on an unpublished framework contract."
|
||||
- "Users, User Groups and Organisations are all reachable from the admin navigation item `user`, each side item shown only to an admin holding its permission."
|
||||
- "The plugin's changes are additive: no existing exported function, route, payload or shipped migration changes, and no Go module is added."
|
||||
- "UI P2 error: On the Users form a validation failure marks the field (422) and a refused privileged-group change shows the forbidden banner with `users.privileged_group_forbidden` and marks `groups`."
|
||||
- "UI P4 empty: The User Groups list with no rows shows `groups.list_empty`."
|
||||
- "UI P4 error: A refused privileged-group create, re-code or delete shows `groups.privileged_forbidden`: a banner on save and a toast on delete."
|
||||
- "UI P4 populated: The User Groups list shows Name, Code, Created and Users, with no checkboxes, bulk actions or preview; rows open the update form."
|
||||
- "UI P4 zero-one-many: The User Groups `Users` column shows 0 for a group with no members, not the empty-value dash."
|
||||
- "UI P5 empty: The Organisations list with no rows shows `organisation.list_empty`, and an organisation with no members shows `organisation.members_empty`."
|
||||
- "UI P5 populated: The Organisations list shows Name, Slug and Created; rows open the update form, which has no preview."
|
||||
- "UI P5 partial: The Organisations Members tab shows on update only."
|
||||
- "UI P5 zero-one-many: The members unlink confirm uses `:count` and states that the users are not deleted."
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/models/users_group.go"
|
||||
provides: "UsersGroup pivot model for users_groups (user_id, user_group_id)"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go"
|
||||
provides: "User Groups admin controller with the D-06 guards, users_count and the checkbox permission editor"
|
||||
contains: "golem15.user.usergroups"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go"
|
||||
provides: "Organisations admin controller with the members relation manager"
|
||||
contains: "golem15.user.organisations"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/admin_privileged_test.go"
|
||||
provides: "TestAdminPrivilegedGroups and TestAdminUserGroupsField"
|
||||
- path: "../fonoteka.go/parity/schema_diff_test.go"
|
||||
provides: "allowedDiffs entry for golem15_user_frontend_permissions"
|
||||
contains: "golem15_user_frontend_permissions"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/privileged.go"
|
||||
via: "AdminRelationLocks returns the privileged group ids for an admin without the extra permission"
|
||||
pattern: "AdminRelationLocks"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/privileged.go"
|
||||
via: "FormBeforeCreate, FormBeforeUpdate and FormBeforeDelete compare the stored and the submitted code against the privileged list"
|
||||
pattern: "IsPrivilegedCode"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/admin_navigation.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go"
|
||||
via: "side items users, usergroups and organisations point at the three registered controllers"
|
||||
pattern: "golem15.user.organisations"
|
||||
- from: "../fonoteka.go/parity/schema_diff_test.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go"
|
||||
via: "the allow-list names the Go-only table the migration creates"
|
||||
pattern: "golem15_user_frontend_permissions"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): 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, an admin manages a user's groups from the user form, with the site-admin group protected by its own permission; manages user groups and their frontend permissions; manages organisations and their members; and the application repository carries all of it.
|
||||
|
||||
<objective>
|
||||
Close success criteria 2 and 4 and the rest of criterion 1: add the `groups` field to the user form together with the privileged-group guard (D-04 to D-07, threat T-12-18 revisited), port the User Groups and Organisations screens (D-06, D-22, D-24) with the organisation `members` relation manager, then record the plugin in the application repository (parity allow-list entry, submodule pointer) on a framework that contains v0.1.3.
|
||||
|
||||
Purpose: the admin form becomes the first writer of `users_groups`; it must never be able to make a site admin without the required permission.
|
||||
Output: the files in the frontmatter, committed in the plugin checkout and in fonoteka.go.
|
||||
|
||||
Repos: plugin code is committed inside the plugin checkout with `git -C ../fonoteka.go/plugins/golem15/user` (repo sm-user-plugin) and never staged from the application repository. The application repository (`git -C ../fonoteka.go`) gets one local commit in Task 4 (the allow-list entry together with the bumped pointer). Nothing is pushed by this plan: the plugin is published in plan 05 Task 3, after the security review and only when the framework tag v0.1.3 is on origin; pushing fonoteka.go stays a step for the user after that. Planning docs are committed separately in summercms.go. Never add co-author tags. The PHP reference is read-only. The plugin is shared across projects: additive changes only.
|
||||
|
||||
The groups field and its guard land in the same commit (Task 1), so there is no commit at which `users_groups` has an unguarded writer.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md
|
||||
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-PLAN.md
|
||||
|
||||
<interfaces>
|
||||
- Framework contracts at v0.1.3: `cabana.FieldRelationContract` (belongsToMany needs `NewPivot`, `ParentForeignKey`, `RelatedForeignKey`), `cabana.RelationLock{IDs, Message}`, `cabana.RelationLockProvider.AdminRelationLocks(ctx, field)`, `cabana.ForbiddenError{Message, Details}`, `cabana.ValidationError`, `cabana.Allows(principal, codes)`, `cabana.TxFromContext`, `cabana.RelationContract` (`Kind: cabana.RelationHasMany`, `ForeignKey`, `Columns`), `cabana.AdminRelationContractProvider`, `cabana.PermissionEditorProvider`, `pact.FormBeforeCreate`, `pact.FormBeforeUpdate`, `pact.FormBeforeDelete`, `pact.FormAfterDelete`, `pact.ListExtendQuery`, `pact.RelationExtendManageQuery`; YAML keys `preset`, `invisible`, `type: permissioneditor` with `mode: checkbox`, `type: fileupload`, `type: relation-manager`; `lagoon.HasBeforeValidate` (`BeforeValidate(tx *gorm.DB) error`); `lagoon.Validate` supports only nullable, required, integer, numeric, between, min, max, in, unique, boolean, email, confirmed, different, mimes (any other token turns a save into a 500).
|
||||
- From plan 03 (final names in 12.1-03-SUMMARY.md): `usersAdminController` (AdminFieldRelations with `organisation`, FormRules, FormVirtualFields, hooks), `controllers.AdminControllers`, the explicit embed list in `admin.go`, `Navigation()` with the side item `users`, the permission code `golem15.users.manage_privileged_groups` (registered, no default role), the config key `golem15.user.privileged_groups` and `classes/privileged.go` with `classes.PermissionManagePrivilegedGroups`, `classes.PrivilegedGroupCodes`, `classes.IsPrivilegedCode`, `classes.PrivilegedGroupIDs` and `classes.IsPrivilegedMember`, the D-30 takeover guard at the top of the users controller's `FormBeforeUpdate` and in its `FormBeforeDelete` (test `TestAdminPrivilegedMember`), `models.PermissionSet`, `classes.FrontendPermissionOptions`, `classes.ForceDeleteCleanup` (already removes a deleted user's users_groups rows), the harness helpers in `admin_harness_test.go`.
|
||||
- Plugin models: `models.UserGroup{ID, Name, Code *string, Description *string, Permissions *string, CreatedAt, UpdatedAt *time.Time}` (no Fillable, no Rules yet), `models.Organisation{ID, Slug, Name, Description *string, CreatedAt, UpdatedAt}` (no Fillable, Rules, MorphName yet), `models.User.OrganisationID *uint`, `models.User.Groups` (many2many users_groups, json "-"). The pivot `users_groups` has columns user_id and user_group_id with the composite primary key named user_group and no foreign keys.
|
||||
- `classes.HasGroupCode(ctx, db, userID, code)` and `classes.UserGroupCodes`: the application's site-admin predicate reads group code `admin` through them (`../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go`).
|
||||
- Config: `compass.Config` has `String`, `Int`, `Bool`, `Has`, `Lookup`, `LoadSection`; plugin defaults live in `config/config.yaml` under the namespace `golem15.user`.
|
||||
- Application: `../fonoteka.go/parity/schema_diff_test.go` (`allowedDiffs`; the existing `user_groups` entry is the wording model; `users` is special-cased and needs no entry; `golem15_user_organisations` is column-diffed, so it gets no new column), `TestSchemaMatchesPHPSnapshot`, `TestUserAPINuxtFlows`, `TestPhase12Threats` (subtest T-12-18).
|
||||
- PHP reference (read-only): `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user` (`controllers/UserGroups.php`, `controllers/usergroups/*`, `models/usergroup/*.yaml`, `models/UserGroup.php`, `controllers/Organisations.php`, `controllers/organisations/*`, `models/organisation/*.yaml`, `models/Organisation.php`, `lang/en/lang.php`, `lang/pl/lang.php`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- Reused from plan 03, not created or changed here: the config key `golem15.user.privileged_groups` and, in `classes/privileged.go`, the constant `PermissionManagePrivilegedGroups` and `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs`, `IsPrivilegedMember`.
|
||||
- Models: `models.UsersGroup` (pivot); `models.UserGroup` gains `Fillable()`, `Rules()` and the read-only field `UsersCount`; `models.Organisation` gains `Fillable()`, `Rules()`, `MorphName()`, `AttachRelations()`, `BeforeValidate()`; `models.Slugify`; `models.ParsePermissionSet` and `PermissionSet.Text` helpers.
|
||||
- Controllers: `golem15.user.usergroups` (model name `Golem15\User\Models\UserGroup`, config dir `controllers/usergroups`) and `golem15.user.organisations` (model name `Golem15\User\Models\Organisation`, config dir `controllers/organisations`); the users controller gains the `groups` relation contract and `AdminRelationLocks`.
|
||||
- YAML: `controllers/usergroups/config_list.yaml`, `config_form.yaml`; `models/usergroup/fields.yaml`, `columns.yaml`; `controllers/organisations/config_list.yaml`, `config_form.yaml`, `config_relation.yaml`; `models/organisation/fields.yaml`, `columns.yaml`; the `groups` field in `models/user/fields.yaml`.
|
||||
- Navigation side items `usergroups` and `organisations`.
|
||||
- Lang keys under `golem15.user::lang.group.*`, `groups.*`, `organisation.*`, plus `user.groups`, `user.empty_groups`, `users.privileged_group_forbidden`.
|
||||
- Tests: `TestAdminPrivilegedGroups`, `TestAdminUserGroupsField`, `TestAdminGroups`, `TestAdminOrganisations`, `TestAdminOrganisationMembers`.
|
||||
- Application: one `allowedDiffs` entry and the submodule pointer of `plugins/golem15/user`.
|
||||
|
||||
## Planner decisions recorded for this plan
|
||||
|
||||
- **Config key (D-05 discretion).** `golem15.user.privileged_groups`. Since the plan-check revision the key and `classes/privileged.go` are created by plan 03 Task 1, where the D-30 guard needs them first; this plan only uses them.
|
||||
- **Single writer, and how success criterion 2 is met.** No groups relation manager is added on the user form or on the group form: the relation manager's unlink has no hook to guard (RESEARCH anti-pattern). The user form's `groups` relation field (PHP's `type: relation` field) is the only place a membership changes. A user's groups are managed through that relation field and an organisation's members through a relation manager, as in PHP; ROADMAP success criterion 2 was reworded at the plan check to say exactly that.
|
||||
- **Publication (plan-check revision).** This plan no longer pushes sm-user-plugin. Of the two routes the orchestrator offered, the push moved to plan 05 Task 3 (after the security review, and only when `git ls-remote --tags origin v0.1.3` lists the framework tag); until then the pointer bumps of Task 4 and of plan 05 Task 2 are local commits in fonoteka.go, which these plans never push.
|
||||
- **What D-06 covers.** Exactly the three cases D-06 names (create with a privileged code, change a code to or from a privileged one, delete a privileged group). Editing the name, description or frontend permissions of a privileged group without touching its code needs only `golem15.users.access_groups`.
|
||||
- **Group permissions column.** `UserGroup.Permissions` stays `*string` so the exported model does not change; the controller converts through `PermissionSet` helpers.
|
||||
- **Unsupported rule tokens.** PHP's `regex` (group code) and `alpha_dash` (organisation slug) are checked in the controllers' before-hooks and answered as 422, because `lagoon.Validate` does not know them (RESEARCH Pitfall 2).
|
||||
- **Deleting an organisation with members.** The members' `organisation_id` and `organisation_role` are cleared in the same transaction before the delete; the users are not deleted.
|
||||
- **Framework baseline.** The application resolves the framework through its local replace, so no go.mod version line changes; "builds on v0.1.3" is proven by the tag being an ancestor of the framework head the suites run against.
|
||||
- **Spec-less probe fallback: skipped** (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "T-12-18: Every users_groups Write Path", Pitfalls 2 and 13 and the UI-SPEC rows lifted into `must_haves`.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: An admin sets a user's groups from the user form; the site-admin group shows locked and cannot be added or removed without the privileged-groups permission</name>
|
||||
<reversibility rating="costly">D-04 (user-confirmed): the permission code is stored in backend roles; renaming it later means rewriting role rows in every host application.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/models/users_group.go, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("T-12-18: Every users_groups Write Path"; Security Domain), .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S6; "Plugin screens": Users form groups and forbidden save), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (privileged.go section; field relations in the users controller section), .planning/phases/12-p-ytarium-api-collections-and-albums/12-01-PLAN.md (threat T-12-18 as recorded), .planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/plugins/golem15/user/classes/privileged.go (from plan 03; reused unchanged), ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/plugin.go (how config is read), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go (a pivot model), ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go (the site-admin predicate), modules/cabana/relation_field.go (RelationLockProvider, checkRelationLocks), docs/backend/relation-manager.md (locked options), modules/compass/config.go</read_first>
|
||||
<action>Per D-04, D-05, D-07, D-08 and D-30; RESEARCH "T-12-18" path 1; UI-SPEC S6. Repo: sm-user-plugin; the field, the pivot model and the guard go into one commit.
|
||||
|
||||
(1) Reuse, do not redefine: the config key golem15.user.privileged_groups (default admin) and, in classes/privileged.go, the constant PermissionManagePrivilegedGroups and the functions PrivilegedGroupCodes, IsPrivilegedCode, PrivilegedGroupIDs and IsPrivilegedMember exist since plan 03 Task 1, where the takeover guard of D-30 needed them. This task changes neither config/config.yaml nor classes/privileged.go; the list is still read from the config on every request and nothing is cached at boot.
|
||||
|
||||
(2) models/users_group.go: `UsersGroup` with UserID (column user_id) and UserGroupID (column user_group_id), both part of the primary key, table users_groups; registered like the other models.
|
||||
|
||||
(3) Users controller: AdminFieldRelations gains the contract for the field groups (kind belongsToMany, related model UserGroup, pivot model UsersGroup, parent foreign key user_id, related foreign key user_group_id, label column name). The controller implements cabana.RelationLockProvider: for the field groups, when the principal on the context passes cabana.Allows for PermissionManagePrivilegedGroups the lock is empty; otherwise it carries the privileged group ids (read with the request's database handle) and the message golem15.user::lang.users.privileged_group_forbidden; every other field gets an empty lock. Add the assertion line for the new interface in admin_registry.go. The framework then marks those options and labels locked and refuses, on create and on update, any save that changes the privileged subset with 403 before a row is written. No relation manager for groups is added.
|
||||
|
||||
(4) models/user/fields.yaml: add groups (type relation, tab account, label golem15.user::lang.user.groups, emptyOption golem15.user::lang.user.empty_groups). lang (en and pl): user.groups, user.empty_groups, and users.privileged_group_forbidden with the UI-SPEC text ("You do not have permission to change membership of privileged groups. Nothing was saved." and its Polish text).
|
||||
|
||||
(5) admin_privileged_test.go. `TestAdminUserGroupsField`: an admin with golem15.users.access_users sets a user's ordinary groups, the pivot holds exactly the chosen groups, clearing the field removes them, an unknown group id answers 422, and the user API payload of that user still carries groups as an empty list. `TestAdminPrivilegedGroups` (matrix with and without golem15.users.manage_privileged_groups, a group with code admin and an ordinary group, both created by the test because the migrations seed only guest and registered): without the permission the relation options and the record's labels flag the admin group locked; adding it on update answers 403 with the message key's text and details on groups, and neither the pivot nor any column of the user changed although the same body also changed the name; removing it from a user who has it answers 403 and the pivot is unchanged; creating a user with it answers 403 and no user row exists; changing only ordinary groups while the privileged membership stays as it is answers 200; classes.HasGroupCode for code admin gives the same answer before and after each refused request. With the permission each of those requests answers 200 and the pivot reflects it. With the config key overridden to another code, that group is the locked one and admin is not. A group whose code is NULL is never locked. The same test also ties the field to the takeover guard of plan 03 (D-30), without any direct insert into users_groups: after an admin holding the permission adds the admin group to a user through the groups field, an admin without it gets 403 when changing that user's email; after the holder removes the group again through the field, the same email change answers 200.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminPrivilegedGroups|TestAdminUserGroupsField)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestAdminPrivilegedGroups" or "--- PASS: TestAdminUserGroupsField"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedGroups$' -count=1 -v` prints "--- PASS: TestAdminPrivilegedGroups"; the test asserts 403 with an unchanged pivot for add and for remove on update, 403 with no new user row on create, 200 for an ordinary-group change, and the unchanged answer of classes.HasGroupCode after each refused request; it also asserts that a privileged membership given through the groups field makes that user's email change answer 403 for an admin without the permission (D-30).
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user merge-base --is-ancestor "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go)" "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- models/users_group.go)"` succeeds and the two shas differ (the helpers of plan 03 predate the groups field; they are reused, not recreated).
|
||||
- `grep -c 'AdminRelationLocks' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go` prints at least 1.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user log -1 --format=%H -- models/user/fields.yaml` and `git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- models/users_group.go` print the same commit sha, and that commit is the one that introduces the lock provider: with that sha as SHA, `git -C ../fonoteka.go/plugins/golem15/user grep -q 'AdminRelationLocks' SHA -- controllers/users_admin_controller.go` exits 0 and the same command with `SHA^` in place of SHA exits 1 (the field, the pivot model and its guard landed together).
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$' -count=1` passes (User.Groups is still never serialized).
|
||||
</acceptance_criteria>
|
||||
<done>Group membership is managed from the user form; making or unmaking a site admin needs the privileged-groups permission, enforced on the server for create and update, and an admin without it sees the group locked.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: An admin with the groups permission lists, creates, edits and deletes user groups with their frontend permissions; privileged codes are protected</name>
|
||||
<reversibility rating="reversible">Plugin controller, YAML and additive model methods on the tagged framework contract.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/models/permission_set.go, ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml, ../fonoteka.go/plugins/golem15/user/models/usergroup/columns.yaml, ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_form.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_groups_test.go, ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": User Groups), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" User Groups; gaps G9 and G10; "T-12-18" paths 3 and 4; Pitfall 2; Open Question 3, resolved by D-29), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (usergroups controller section; plugin YAML), ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/models/permission_set.go, ../fonoteka.go/plugins/golem15/user/classes/permissions.go (FrontendPermissionOptions), ../fonoteka.go/plugins/golem15/user/classes/privileged.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go (hooks reading the transaction and the principal), ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, docs/backend/forms.md (permission editor, preset), docs/backend/lists-and-filters.md, modules/lagoon/validate.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/UserGroups.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/usergroups/config_list.yaml and config_form.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/usergroup/fields.yaml and columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/UserGroup.php</read_first>
|
||||
<action>Per D-06, D-15, D-16, D-24 and D-29; UI-SPEC "User Groups". Repo: sm-user-plugin.
|
||||
|
||||
(1) models/user_group.go: add `Fillable()` returning name, code and description; `Rules()` with name "required|between:3,64" and code "required|unique:user_groups" (only tokens lagoon.Validate supports); a read-only field `UsersCount` on a column named users_count with the arrow-only GORM permission and json "-" (the column exists only in the list query's select). Permissions stays a nullable string. models/permission_set.go: helpers `ParsePermissionSet(text)` (the tolerant decode used by Scan, for a nullable string column) and `PermissionSet.Text()` (the object text, or nil for an empty set).
|
||||
|
||||
(2) controllers/usergroups_admin_controller.go: ID golem15.user.usergroups; ModelName the PHP class string of the UserGroup model; ConfigDir controllers/usergroups; RequiredPermissions golem15.users.access_groups; NewRecord a new models.UserGroup. ListExtendQuery selects the group columns plus, as a second select argument, a scalar subquery counting the group's users_groups rows aliased users_count (two select arguments, so the list's count query stays a plain count). FormBeforeCreate and FormBeforeUpdate: (a) refuse a code that does not match the pattern of letters, digits, underscore and hyphen only with a ValidationError on code; (b) the D-06 check: read the stored code of the group by id through the request's transaction (none on create) and compare it with the submitted code; when they differ in privilege (the submitted code is privileged on create; on update the stored or the submitted code is privileged and the two codes differ) and the principal does not pass cabana.Allows for PermissionManagePrivilegedGroups, return a cabana.ForbiddenError with the message golem15.user::lang.groups.privileged_forbidden and details on code. FormBeforeDelete: a group whose stored code is privileged needs the same permission, else the same ForbiddenError. FormAfterDelete: delete the group's users_groups rows in the same transaction. PermissionEditorProvider for the field permissions: options from classes.FrontendPermissionOptions, values parsed from the nullable Permissions text, the setter writing the object text back (checkbox mode stores 1 per allowed code). Register the controller in controllers.AdminControllers and add its assertion lines.
|
||||
|
||||
(3) YAML. controllers/usergroups/config_list.yaml: list pointing at models/usergroup/columns.yaml, the modelClass, title golem15.user::lang.groups.list_title, recordUrl golem15/user/usergroups/update/:id, noRecordsMessage, recordsPerPage 20, showSetup true, showSorting true, no showCheckboxes, toolbar buttons [create] with the search prompt, messages empty golem15.user::lang.groups.list_empty and create golem15.user::lang.groups.new_group. models/usergroup/columns.yaml: id (invisible), name (searchable), code, created_at (type datetime), users_count (label golem15.user::lang.group.users_count, sortable false). controllers/usergroups/config_form.yaml: name, form pointing at models/usergroup/fields.yaml, the modelClass, defaultRedirect golem15/user/usergroups, create and update redirects to the update form and the list, messages (create, update, saved, deleteConfirm golem15.user::lang.groups.delete_confirm, deleted); no title keys under create or update and no preview block. models/usergroup/fields.yaml: name (type text, span left, required), code (type text, span right, preset name, comment golem15.user::lang.group.code_comment), description (type textarea, size tiny, label golem15.user::lang.group.description_field), permissions (type permissioneditor, mode checkbox, span full, tab golem15.user::lang.user.permissions_tab). Add every new file to the embed list in admin.go.
|
||||
|
||||
(4) admin_navigation.go: add the side item usergroups (label golem15.user::lang.groups.all_groups, icon users, permission golem15.users.access_groups, controller golem15.user.usergroups).
|
||||
|
||||
(5) lang (en and pl), PHP values plus the UI-SPEC texts for new keys: group.id, name, code, code_comment, description_field, created_at, users_count; groups.all_groups, menu_label, list_title, new_group, list_empty, delete_confirm, privileged_forbidden.
|
||||
|
||||
(6) Tests. admin_groups_test.go `TestAdminGroups`: an admin with only golem15.users.access_users gets 403 on the groups list and sees no usergroups side item; an admin with golem15.users.access_groups lists the groups with users_count (0 for a group without members, the member count otherwise) and the list total is right; creates a group (a code with a space answers 422 on code; a duplicate code answers 422); edits name and description; saves two frontend permissions in checkbox mode and user_groups.permissions holds a JSON object with value 1 for each; a value of -1 answers 422; deletes an ordinary group and its users_groups rows are gone while the member users remain. Extend `TestAdminPrivilegedGroups` in admin_privileged_test.go with the D-06 matrix: without the extra permission creating a group with code admin, renaming an ordinary group's code to admin, renaming the admin group's code to something else and deleting the admin group each answer 403 and change nothing; editing the admin group's name alone answers 200; with the permission all four succeed.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminGroups|TestAdminPrivilegedGroups)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestAdminGroups" or "--- PASS: TestAdminPrivilegedGroups"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminGroups$' -count=1 -v` prints "--- PASS: TestAdminGroups"; the test asserts users_count 0 for an empty group, the correct list total, a 422 for an invalid code and the pivot cleanup on delete.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedGroups$' -count=1 -v` prints "--- PASS: TestAdminPrivilegedGroups" with the four D-06 cases refused without the permission and allowed with it.
|
||||
- `grep -c 'golem15.users.access_groups' ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go` prints at least 1.
|
||||
- `grep -c 'mode: checkbox' ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml` prints 1 and `grep -c 'preset: name' ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml` prints 1.
|
||||
- `go -C ../fonoteka.go/plugins/golem15/user doc ./models UserGroup` still shows Permissions as a pointer to string (the exported model field kept its type).
|
||||
</acceptance_criteria>
|
||||
<done>User Groups is a working admin screen with the users count and the checkbox permission editor, and a privileged group code can be created, renamed or deleted only with the privileged-groups permission.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: An admin lists, creates and edits organisations with an avatar and manages each organisation's members</name>
|
||||
<reversibility rating="reversible">Plugin controller, YAML and additive model methods; the shared organisations table gets no new column.</reversibility>
|
||||
<files>../fonoteka.go/plugins/golem15/user/models/organisation.go, ../fonoteka.go/plugins/golem15/user/models/slug.go, ../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml, ../fonoteka.go/plugins/golem15/user/models/organisation/columns.yaml, ../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_form.yaml, ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/plugins/golem15/user/admin_organisations_test.go</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Organisations), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" Organisations; "Relation fields and relation managers"; "File upload (D-20)"; gap G10; "T-12-18" path 6), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (organisations controller section; config_relation.yaml), ../fonoteka.go/plugins/golem15/user/models/organisation.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/updates/10_organisations.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go (AdminRelationContracts, RelationExtendManageQuery failing closed), ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, ../fonoteka.go/plugins/golem15/feedback/models/submission.go (MorphName, AttachRelations), modules/cabana/relation.go (RelationContract, hasMany link and unlink), docs/backend/relation-manager.md, docs/backend/forms.md (file uploads, preset), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/Organisations.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/organisations/config_list.yaml, config_form.yaml and config_relation.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/organisation/fields.yaml and columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/Organisation.php</read_first>
|
||||
<action>Per D-22 and D-20; UI-SPEC "Organisations". Repo: sm-user-plugin.
|
||||
|
||||
(1) models/organisation.go: add `Fillable()` (name, slug, description), `Rules()` (name "required|max:255", slug "required|unique:golem15_user_organisations", description "nullable|max:5000"), `MorphName()` returning the PHP class string of the Organisation model, `AttachRelations()` declaring the public relation named avatar, and `BeforeValidate` (lagoon.HasBeforeValidate) that fills an empty slug from the name with Slugify. No field or column is added to the model (the table is shared and column-diffed against the PHP snapshot). models/slug.go (in the models package, because the classes package imports models): `Slugify(text)` (lower-case ASCII; every run of other characters becomes one hyphen; hyphens trimmed at both ends), the same rule the SPA preset uses.
|
||||
|
||||
(2) controllers/organisations_admin_controller.go: ID golem15.user.organisations; ModelName the PHP class string; ConfigDir controllers/organisations; RequiredPermissions golem15.users.access_users; NewRecord a new models.Organisation. FormBeforeCreate and FormBeforeUpdate refuse a slug that contains anything but letters, digits, underscore and hyphen with a ValidationError on slug. AdminRelationContracts (cabana.AdminRelationContractProvider): one contract named members, kind cabana.RelationHasMany, related model models.User, foreign key organisation_id, columns map name to name and email to email. RelationExtendManageQuery returns a query that matches nothing for any relation name other than members. FormBeforeDelete clears organisation_id and organisation_role of the organisation's members in the same transaction, so the delete does not fail on the users foreign key and no user is deleted. Register the controller and its assertion lines.
|
||||
|
||||
(3) YAML. controllers/organisations/config_list.yaml: list pointing at models/organisation/columns.yaml, the modelClass, title, recordUrl golem15/user/organisations/update/:id, recordsPerPage 20, toolbar buttons [create] with the search prompt, no showCheckboxes, messages empty golem15.user::lang.organisation.list_empty and create golem15.user::lang.organisation.new. models/organisation/columns.yaml: id (invisible), name (searchable), slug (searchable), created_at (type datetime, sortable true, label backend::lang.list.column_created). controllers/organisations/config_form.yaml: name, form, modelClass, defaultRedirect golem15/user/organisations, redirects, messages; no preview block. models/organisation/fields.yaml: name (type text, span left, required), slug (type text, span right, comment golem15.user::lang.organisation.slug_comment, preset with field name and type slug), description (type textarea, size small), avatar (type fileupload, mode image, imageWidth 120, imageHeight 120), members (type relation-manager, relation members, tab golem15.user::lang.organisation.members, span full, context update). controllers/organisations/config_relation.yaml: members with label golem15.user::lang.organisation.members, a messages block (link golem15.user::lang.organisation.add_member, unlinkSelected golem15.user::lang.organisation.remove_members, unlinkConfirm golem15.user::lang.organisation.remove_members_confirm, empty golem15.user::lang.organisation.members_empty), view with inline list columns name and email, toolbarButtons link|unlink and showSearch true, manage with the same inline columns and showSearch true. Add every new file to the embed list in admin.go.
|
||||
|
||||
(4) admin_navigation.go: add the side item organisations (label golem15.user::lang.organisation.menu_label, icon users-round, permission golem15.users.access_users, controller golem15.user.organisations).
|
||||
|
||||
(5) lang (en and pl), PHP values plus the UI-SPEC texts for new keys: organisation.menu_label, id, name, slug, slug_comment, description, avatar, members, new, list_empty, add_member, remove_members, remove_members_confirm (with :count), members_empty.
|
||||
|
||||
(6) README.md of the plugin: complete the admin section with User Groups (the D-06 rule, the config key golem15.user.privileged_groups, the single-writer rule for users_groups) and Organisations (members), still naming no consuming application.
|
||||
|
||||
(7) admin_organisations_test.go. `TestAdminOrganisations`: an admin with golem15.users.access_users lists organisations, creates one with an empty slug and gets the slug derived from the name, a slug with a space answers 422, a duplicate slug answers 422, an avatar uploaded through the admin file route is stored against the organisation's morph name, and deleting an organisation with members succeeds, leaves the users in place and clears their organisation_id; an admin without the permission gets 403. `TestAdminOrganisationMembers`: linking two existing users sets their organisation_id, unlinking one clears it, the linked list shows only this organisation's members, a user of another organisation moves when linked here, and the users_groups rows of every involved user are unchanged throughout. The navigation of an admin holding both users permissions lists the side items users, usergroups and organisations in that order.</action>
|
||||
<verify>
|
||||
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminOrganisations|TestAdminOrganisationMembers)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestAdminOrganisations" or "--- PASS: TestAdminOrganisationMembers"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminOrganisationMembers$' -count=1 -v` prints "--- PASS: TestAdminOrganisationMembers"; the test asserts that link sets and unlink clears organisation_id and that users_groups is unchanged.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminOrganisations$' -count=1 -v` prints "--- PASS: TestAdminOrganisations".
|
||||
- `grep -c 'imageWidth: 120' ../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml` prints 1 and `grep -c 'link|unlink' ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml` prints 1.
|
||||
- `git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- updates/10_organisations.go` prints nothing (no column was added to the shared organisations table).
|
||||
- `grep -c 'golem15.user.organisations' ../fonoteka.go/plugins/golem15/user/admin_navigation.go` prints 1 and `grep -c 'golem15.user.usergroups' ../fonoteka.go/plugins/golem15/user/admin_navigation.go` prints 1.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1` passes and `git -C ../fonoteka.go/plugins/golem15/user status --short` prints nothing after the task's commits.
|
||||
</acceptance_criteria>
|
||||
<done>Organisations is a working admin screen with avatar and a members relation manager, and all three screens are in the navigation under their permissions.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The application repository carries the plugin's admin screens in one local commit: the schema allow-list names the new table and the pointer records the plugin head, on a framework that contains v0.1.3; nothing is pushed</name>
|
||||
<reversibility rating="reversible">One local commit in fonoteka.go (the allow-list entry and the submodule pointer). Nothing is published here: the push of sm-user-plugin, which is the costly step, is plan 05 Task 3.</reversibility>
|
||||
<precondition>`git -C ../fonoteka.go/plugins/golem15/user status --short` prints nothing (every plugin change of plans 03 and 04 is committed), and `git merge-base --is-ancestor v0.1.3 HEAD` succeeds in summercms.go.</precondition>
|
||||
<files>../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/plugins/golem15/user</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("Parity schema allow-list"; Pitfall 13), ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/.gitmodules, ../fonoteka.go/go.mod, .planning/phases/12-p-ytarium-api-collections-and-albums/12-01-PLAN.md (how the user plugin was pushed and its pointer bumped), .planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md</read_first>
|
||||
<action>Per D-25 and RESEARCH Pitfall 13. Repo: fonoteka.go (one local commit). Nothing is pushed by this task, neither sm-user-plugin nor fonoteka.go. Before anything else, read the plugin's remote head with `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` and note the sha for the summary.
|
||||
|
||||
(1) ../fonoteka.go/parity/schema_diff_test.go: add one allowedDiffs entry keyed golem15_user_frontend_permissions, worded like the existing user_groups entry and naming its source: Winter's frontend permissions table from the user plugin's updates/v2.8.0 (Phase 12.1 D-15); the frozen snapshot predates the user-plugin dump; same columns as PHP with TIMESTAMPTZ timestamps. Add no entry for users (the snapshot's users table already has permissions and last_seen, and the test only requires PHP to have more columns than Go) and none for golem15_user_organisations.
|
||||
|
||||
(2) Confirm the framework baseline: the tag v0.1.3 is an ancestor of the summercms.go head the application builds against (the application resolves the framework through its local replace; no go.mod version line changes).
|
||||
|
||||
(3) Run the application's whole suite against the plugin's working copy: go vet and go test over the application workspace, including the schema parity test and the user API parity flows.
|
||||
|
||||
(4) Do not push the plugin. Its checkout stays ahead of its origin until plan 05 Task 3, which pushes master once, after the security review, and only when `git ls-remote --tags origin v0.1.3` lists the framework tag: a published plugin commit must never depend on a framework contract that is not published, and the shared plugin is not published before the review. Never force a push.
|
||||
|
||||
(5) In fonoteka.go, stage parity/schema_diff_test.go and the submodule pointer of plugins/golem15/user only, and commit them together (for example "build: bump sm-user-plugin (admin screens for users, groups and organisations)"). Do not stage any other submodule and do not push fonoteka.go. The pointer now names a plugin commit that is not on the plugin's origin; that is safe only while fonoteka.go itself is unpushed, so record the two pending steps in the summary in this order: first "push sm-user-plugin" (done by plan 05 Task 3 when its conditions hold), then "push fonoteka.go" (the user).
|
||||
|
||||
(6) Record in the summary: the plugin head sha and that it is not pushed, the plugin's remote head sha read at the start and again at the end of the task, the application commit sha, the framework head sha and the measured run times of the two full suites.</action>
|
||||
<verify>
|
||||
<automated>git merge-base --is-ancestor v0.1.3 HEAD && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestUserAPINuxtFlows|TestParityCorpus)$' -count=1 -v && go -C ../fonoteka.go test ./... -count=1 && test -z "$(git -C ../fonoteka.go/plugins/golem15/user status --short)" && test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD)" && git -C ../fonoteka.go/plugins/golem15/user fetch --quiet origin && test -n "$(git ls-remote --tags origin v0.1.3)" -o "$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD)" != "0"</automated>
|
||||
<fails_when>Any command exits non-zero; the parity run lacks "--- PASS: TestSchemaMatchesPHPSnapshot" or prints "Go extra table"; the full application run prints a line starting with "FAIL"; the pointer recorded in fonoteka.go differs from the plugin checkout's head; the last term exits 1, which means the plugin checkout has no commit ahead of its freshly fetched origin (its head is published) while the framework tag v0.1.3 is not on the framework's origin.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go -C ../fonoteka.go test ./parity -run '^TestSchemaMatchesPHPSnapshot$' -count=1 -v` prints "--- PASS: TestSchemaMatchesPHPSnapshot".
|
||||
- `grep -c '"golem15_user_frontend_permissions"' ../fonoteka.go/parity/schema_diff_test.go` prints 1.
|
||||
- `git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user` equals `git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD`.
|
||||
- The sha printed by `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` at the end of the task equals the sha noted at its start (both are in the summary): this task pushed nothing.
|
||||
- The summary lists the pending steps in order: "push sm-user-plugin" (plan 05 Task 3, conditional on v0.1.3 being on origin), then "push fonoteka.go".
|
||||
- `git -C ../fonoteka.go show --stat --format= HEAD` lists exactly parity/schema_diff_test.go and plugins/golem15/user.
|
||||
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` exits 0 at that commit, and `git -C ../fonoteka.go diff HEAD -- go.mod` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The application builds and passes with the plugin's admin screens, recorded by a local pointer commit on a framework that contains v0.1.3; publication waits for plan 05.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Backend admin → users_groups | The admin form is the first writer of group membership; membership of the privileged group makes a site admin |
|
||||
| Backend admin → user_groups codes | Changing a group's code changes the privilege of every member |
|
||||
| Configuration → privilege | The privileged list decides which codes are protected |
|
||||
| Plugin repository → host applications | A push publishes migrations and permission codes to every project mounting the plugin; this plan does not push (the push is plan 05 Task 3, threat T-12.1-40) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.1-28 | Elevation of Privilege | T-12-18 revisited: adding or removing a privileged group through the user form's groups field | critical | mitigate | `AdminRelationLocks` returns the privileged group ids for an admin without `golem15.users.manage_privileged_groups`; the framework refuses any change of that subset with 403 before a row is written, on create and update; field and guard land in one commit (Task 1, TestAdminPrivilegedGroups). |
|
||||
| T-12.1-29 | Elevation of Privilege | creating, renaming to or from, or deleting a privileged group code | high | mitigate | Before-hooks compare the stored and the submitted code against the privileged list and return `ForbiddenError` without the extra permission (Task 2, TestAdminPrivilegedGroups D-06 matrix). |
|
||||
| T-12.1-30 | Elevation of Privilege | a second, unguarded writer of users_groups | high | mitigate | No groups relation manager exists; bulk actions, record actions and the organisation routes are asserted to leave users_groups unchanged; only the cleanup of a permanently deleted user or group removes rows (Tasks 1 to 3; plan 03 Task 2). |
|
||||
| T-12.1-31 | Tampering | privileged list cached, mis-compared or bypassed through a NULL code | medium | mitigate | Read from config per request, default [admin], case-sensitive compare, NULL code never privileged; tested with an overridden list (Task 1). |
|
||||
| T-12.1-32 | Tampering | organisation members manager moves users between organisations | low | accept | PHP's add and remove behave the same; the routes need `golem15.users.access_users`, write only organisation_id and are scoped by the relation contract (RESEARCH T-12-18 path 6). |
|
||||
| T-12.1-33 | Tampering | a published application pointer references an unpublished plugin commit, the plugin is published while the framework contract it needs is not, or the app is built against a framework without v0.1.3 | low | mitigate | Task 4 pushes nothing: the pointer bump is a local commit in fonoteka.go, which these plans never push; the summary orders the pending steps (plugin first, then the application); the verify command fails when the plugin's head is on its origin while `git ls-remote --tags origin v0.1.3` is empty, and runs `git merge-base --is-ancestor v0.1.3 HEAD` (Task 4). The push itself is governed by T-12.1-40 in plan 05. |
|
||||
| T-12.1-34 | Information Disclosure | group membership reaches a user API payload | high | mitigate | `User.Groups` keeps json "-", payloads come from the explicit map, TestPhase12Threats and the parity flows run in every task (D-08). |
|
||||
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; `git diff HEAD -- go.mod` in the application and the plugin shows no new require line. Any need for a module stops at a blocking human checkpoint. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1` green after every commit in the plugin checkout.
|
||||
- `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green at the application commit of Task 4.
|
||||
- `go vet ./... && go test ./... -count=1` still green in summercms.go (no framework change in this plan).
|
||||
- The application's pointer equals the plugin head, and nothing was pushed: the plugin's remote head is the sha it was before the plan.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Users, User Groups and Organisations each have a list and a form reachable from the navigation and gated by backend permissions (SC-1); a user's groups are managed through the relation field on the user form and an organisation's members through a relation manager (SC-2).
|
||||
- T-12-18 is closed: the admin form is the only writer of `users_groups`, and privileged membership and privileged codes need `golem15.users.manage_privileged_groups` on the server (SC-4).
|
||||
- The application passes its full suite with the plugin at the head its local pointer commit records, on a framework that contains v0.1.3; neither repository was pushed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-04-SUMMARY.md` when done. Record the commit shas of the three repositories, the plugin's remote head before and after, the pending steps in order ("push sm-user-plugin" in plan 05 Task 3, then "push fonoteka.go") and the measured run times.
|
||||
</output>
|
||||
312
.planning/phases/12.1-user-plugin-admin-screens/12.1-05-PLAN.md
Normal file
312
.planning/phases/12.1-user-plugin-admin-screens/12.1-05-PLAN.md
Normal file
@@ -0,0 +1,312 @@
|
||||
---
|
||||
phase: 12.1-user-plugin-admin-screens
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["12.1-04"]
|
||||
files_modified:
|
||||
- scripts/check-phase12.1.sh
|
||||
- modules/pact/capabilities_test.go
|
||||
- modules/cabana/phase121_threats_test.go
|
||||
- modules/cabana/phase121_bulk_test.go
|
||||
- modules/cabana/phase121_record_test.go
|
||||
- modules/cabana/phase121_rowstate_test.go
|
||||
- modules/cabana/phase121_forbidden_test.go
|
||||
- modules/cabana/phase121_preview_test.go
|
||||
- modules/cabana/phase121_fields_test.go
|
||||
- modules/cabana/phase121_permission_test.go
|
||||
- modules/cabana/phase121_relation_lock_test.go
|
||||
- modules/cabana/phase121_list_test.go
|
||||
- modules/cabana/phase121_schema_boot_test.go
|
||||
- admin/tests/list/BulkActionsMenu.test.ts
|
||||
- admin/tests/list/RowStateBadges.test.ts
|
||||
- admin/tests/list/ListToolbar.test.ts
|
||||
- admin/tests/list/DataTable.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/form/RecordActions.test.ts
|
||||
- admin/tests/form/PreviewView.test.ts
|
||||
- admin/tests/form/PreviewField.test.ts
|
||||
- admin/tests/form/PermissionEditorField.test.ts
|
||||
- admin/tests/form/PasswordField.test.ts
|
||||
- admin/tests/form/RelationField.test.ts
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/form/FormErrorBanner.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/form/registry.test.ts
|
||||
- admin/tests/app/winterUrl.test.ts
|
||||
- admin/tests/app/router.test.ts
|
||||
- ../fonoteka.go/plugins/golem15/user/phase121_security_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_users_edge_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_groups_edge_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_organisations_edge_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/admin_registration_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/admin_actions_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/privileged_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/permissions_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/last_seen_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/permission_set_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/slug_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/admin_models_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user
|
||||
- .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md
|
||||
- .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md
|
||||
autonomous: true
|
||||
requirements: [SC-5]
|
||||
estimate:
|
||||
tokens: 250000
|
||||
raw_tokens: 250000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-26 and the project rule that unit tests are the last plan of a phase, this is plan 05 of five: full unit test coverage for the code of plans 01 to 04 in summercms.go and sm-user-plugin, the fail-closed gate scripts/check-phase12.1.sh, the security review and the validation sign-off."
|
||||
- "ROADMAP success criterion 5: the new code has unit tests, delivered in this last plan; the measured statement coverage of modules/pact, modules/cabana and the user plugin's packages (root, classes, controllers, models, updates) is at least 80 percent each, and the gate refuses a lower number."
|
||||
- "Per D-04 to D-07 (threat T-12-18 revisited), a named threat test per repository (TestPhase121Threats) has one subtest per mitigated T-12.1 threat, and for every high or critical mitigated threat the gate's removal stage proves the test fails when the protection is removed and restores the source byte for byte."
|
||||
- "Per D-30 (threats T-12.1-38 and T-12.1-39), the plugin's TestPhase121Threats has one subtest for the takeover of a privileged-group member (email change, password reset) and one for that member's permanent delete (form delete, bulk delete refused as a whole), each run with and without golem15.users.manage_privileged_groups; the gate's removal stage proves that taking the privileged-member check out of the Users controller's FormBeforeUpdate or out of its FormBeforeDelete makes the matching subtest fail on an assertion."
|
||||
- "Per D-08 and D-17, regression tests prove that a user with groups, permissions and last_seen marshals and is served by every user API route without any of those keys changing: groups stays an empty list, permissions stays null and last_seen is absent."
|
||||
- "Per D-09 and D-10, the framework tests cover the bulk and record action routes for empty, duplicate, unordered, partial, out-of-scope, undeclared, unregistered, permission-denied and CSRF-less requests, rollback on a failing action, and concurrent runs against the same rows."
|
||||
- "Per D-11, D-12, D-16 and D-27, the framework tests cover preview compilation and write refusal, row state reduction, the permission editor's validation and locked codes, password and virtual fields, rules per operation, preset and invisible column compilation, writable foreign keys and relation locks, including every boot error message introduced by plans 01 and 02."
|
||||
- "Per D-13, D-14, D-15, D-19, D-20, D-22, D-23, D-24 and D-29, the plugin tests cover every bulk and record action, permanent delete with cleanup, the password and invitation paths, the avatar shared with the user API, the merged-permission resolver, the three filters, users_count, the organisation members manager and the group delete with its guard."
|
||||
- "Each of the five UI-SPEC backstop statements (bulk menu focus return; mapWinterUrl and the preview route replacement; unknown row state; permission editor emission rules; locked relation options) has a named vitest case, and the SPA components added or changed by plans 01 and 02 have unit tests."
|
||||
- "scripts/check-phase12.1.sh fails closed: a failing, skipped, zero-match or non-building test run, a missing named security test, OpenAPI or dist drift, a docs checker problem, a consuming-application name in a framework file, a coverage number below the floor, or an evidence gap each make it exit non-zero, and its self-test proves each detector on planted input."
|
||||
- "12.1-SECURITY-REVIEW.md lists every threat id T-12.1-01 to T-12.1-40 and T-12.1-SC with its disposition, the protecting code, the test that was run and the observed result, cites T-12-18 by its original id, records the accepted risks (re-enabling a deactivated site admin; moving users between organisations), states the boundary of D-30 (what an admin holding only golem15.users.access_users may still do to a privileged-group member), and reports zero open high or critical threats."
|
||||
- "The shared plugin is published once and deliberately, as the last step of this plan and after the security review: sm-user-plugin master is pushed only when `git ls-remote --tags origin v0.1.3` lists the framework tag, the gate passed, the review reports zero open threats and no framework production code changed after the tag; otherwise the push is skipped and recorded as pending in the summary, never forced. A published plugin commit therefore never depends on a framework contract that is not published. fonoteka.go is not pushed by the phase; its pointer bumps are local commits."
|
||||
- "12.1-VALIDATION.md carries the final per-task map with real task ids, commands and measured run times, has no pending or TBD row, and is signed off with nyquist_compliant true."
|
||||
- "No Go module and no npm package is added or changed in version by this plan; production code changes only where a test exposes a real defect, each as its own commit naming the threat or decision."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase12.1.sh"
|
||||
provides: "fail-closed phase gate with the stages self-test, go, security, removal, coverage, spa, openapi, dist, docs, hygiene, app, evidence, all"
|
||||
contains: "PHASE121_APP"
|
||||
- path: "modules/cabana/phase121_threats_test.go"
|
||||
provides: "TestPhase121Threats for the framework threats T-12.1-01 to T-12.1-16"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/phase121_security_test.go"
|
||||
provides: "TestPhase121Threats for the plugin threats T-12.1-18 to T-12.1-34 plus T-12.1-38 and T-12.1-39 (D-30), including T-12-18 revisited"
|
||||
- path: ".planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md"
|
||||
provides: "security review mapping every threat to a passing test"
|
||||
- path: ".planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md"
|
||||
provides: "signed-off validation map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase12.1.sh"
|
||||
to: "modules/cabana/phase121_threats_test.go"
|
||||
via: "the security stage runs the named tests by prefix through the go test -json detector and refuses a skip or a missing name"
|
||||
pattern: "TestPhase121Threats"
|
||||
- from: "scripts/check-phase12.1.sh"
|
||||
to: "../fonoteka.go/plugins/golem15/user/phase121_security_test.go"
|
||||
via: "the app stage runs the plugin's named tests in the application workspace named by PHASE121_APP"
|
||||
pattern: "PHASE121_APP"
|
||||
- from: ".planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md"
|
||||
to: "scripts/check-phase12.1.sh"
|
||||
via: "the evidence stage checks that every threat id of the five plans appears in the review with a test name"
|
||||
pattern: "T-12.1-"
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): 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: success criterion 5. After it, the phase is closed by evidence: one command proves the framework features, the three screens and the T-12-18 guard, and fails closed when any of them regresses.
|
||||
|
||||
<objective>
|
||||
Bring the code of plans 01 to 04 to full unit test coverage in both repositories (framework Go, admin SPA, the user plugin), add the phase gate `scripts/check-phase12.1.sh` on the pattern of `scripts/check-phase12.sh` and `scripts/check-phase12.2.sh`, write the security review and sign off the validation file.
|
||||
|
||||
Purpose: per the project rule, unit tests are the last plan of a phase; plans 01 to 04 carried tracer and smoke tests only.
|
||||
Output: test files in both repositories, the gate script, 12.1-SECURITY-REVIEW.md and the validated 12.1-VALIDATION.md.
|
||||
|
||||
Repos: summercms.go (framework tests, SPA tests, gate script; planning docs in a separate commit), sm-user-plugin (plugin tests, committed in the plugin checkout with `git -C ../fonoteka.go/plugins/golem15/user`, then the pointer bumped in fonoteka.go as its own local commit). The plugin is pushed once, in the last step of Task 3, after the security review and only when the framework tag v0.1.3 is on origin; otherwise the push stays pending. fonoteka.go is never pushed by this plan. Production code changes only where a test exposes a real defect; each such fix is its own commit naming the threat or decision, with its README, docs, OpenAPI and dist duties when it is framework code. A framework fix made here is after the v0.1.3 tag: record it in the summary as needing a follow-up tag and do not move v0.1.3. Never add co-author tags. Framework tests and the gate's framework-facing output use neutral names.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-01-SUMMARY.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md
|
||||
@.planning/phases/12.1-user-plugin-admin-screens/12.1-04-SUMMARY.md
|
||||
@scripts/check-phase12.sh
|
||||
@scripts/check-phase12.2.sh
|
||||
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md
|
||||
|
||||
<interfaces>
|
||||
- From plans 01 to 04 (final names in their SUMMARY files): the pact and cabana contracts of v0.1.3; the `acme.roster` fixture (`modules/cabana/phase121_fixture_test.go`, `modules/cabana/testdata/roster`, `newRosterEnv` with the auth modes bearer, limited, cookie and cookie-only); the smoke tests `TestBulkActionTracer`, `TestListSchemaBulkActionsBoot`, `TestRecordActionSmoke`, `TestRowStateSmoke`, `TestSoftDeletedRecordSmoke`, `TestForbiddenSmoke`, `TestPreviewSmoke`, `TestPasswordFieldSmoke`, `TestVirtualFieldsSmoke`, `TestFormRulesSmoke`, `TestPresetSchema`, `TestPermissionEditorSmoke`, `TestWritableForeignKeySmoke`, `TestRelationLockSmoke`, `TestInvisibleColumnSmoke`, `TestFilterOptionsController`; the SPA smoke tests `admin/tests/smoke/actions.smoke.test.ts`, `preview.smoke.test.ts`, `seams.smoke.test.ts` and the `roster.*.json` fixtures; the plugin harness `admin_harness_test.go` and the tests `TestAdminUsersTracer`, `TestAdminPrivilegedMember` (D-30), `TestAdminUserActions`, `TestAdminUserForceDelete`, `TestAdminUserPassword`, `TestAdminUserInvite`, `TestAdminAvatarSharedWithAPI`, `TestMergedPermissions`, `TestPermissionSetScan`, `TestLastSeen`, `TestAdminPrivilegedGroups`, `TestAdminUserGroupsField`, `TestAdminGroups`, `TestAdminOrganisations`, `TestAdminOrganisationMembers`.
|
||||
- Threat registers: T-12.1-01 to T-12.1-08 (plan 01), T-12.1-09 to T-12.1-17 (plan 02), T-12.1-18 to T-12.1-27 plus T-12.1-38 and T-12.1-39 (plan 03; the last two were added at the plan check for D-30), T-12.1-28 to T-12.1-34 (plan 04), T-12.1-35 to T-12.1-37 and T-12.1-40 (this plan), and T-12.1-SC in every plan. T-12-18 is the Phase 12 threat this phase revisits.
|
||||
- Gate precedents: `scripts/check-phase12.sh` (stages self-test, go, named, removal, coverage, evidence, all; `phase12_detect` over go test -json with exit codes for fail, skip, zero tests, non-JSON, a required test that did not pass; the anchor-exact removal harness that refuses a dirty file and restores with cmp; `coverage_report`; `evidence_check`) and `scripts/check-phase12.2.sh` (stages go, security, spa, openapi, dist, docs, hygiene, app, all; the `SECURITY_*` prefix arrays; `PHASE_FILES`; `HYGIENE_DOCS`; `APP_NAMES`; the overridable application path).
|
||||
- Existing SPA test style: `admin/tests/list/ListToolbar.test.ts`, `admin/tests/form/DatepickerField.test.ts`, `admin/tests/form/RelationField.test.ts`, `admin/tests/app/winterUrl.test.ts`, `admin/tests/fixtures/typed.ts`, `admin/tests/helpers.ts`.
|
||||
- Existing application regression tests: `TestPhase12Threats` (subtest T-12-18), `TestUserAPINuxtFlows`, `TestParityCorpus`, `TestSchemaMatchesPHPSnapshot`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- Gate: `scripts/check-phase12.1.sh` with `--self-test`, `--go`, `--security`, `--removal`, `--coverage`, `--spa`, `--openapi`, `--dist`, `--docs`, `--hygiene`, `--app`, `--evidence`, `--all`; environment variable `PHASE121_APP` (default `../fonoteka.go`).
|
||||
- Framework tests: `TestPhase121Threats` (cabana), the `TestBulkAction*`, `TestListSchemaBulkActions*`, `TestRecordAction*`, `TestRowState*`, `TestForbidden*`, `TestPreview*`, `TestPasswordField*`, `TestVirtualFields*`, `TestFormRules*`, `TestPreset*`, `TestPermissionEditor*`, `TestRelationLock*`, `TestWritableForeignKey*`, `TestInvisibleColumn*`, `TestFilterOptions*` families in the `modules/cabana/phase121_*_test.go` files; pact contract tests in `modules/pact/capabilities_test.go`.
|
||||
- SPA tests: the vitest files listed in the frontmatter, including the five backstop cases.
|
||||
- Plugin tests: `TestPhase121Threats` (plugin), `TestAdminUsers*`, `TestAdminGroups*`, `TestAdminOrganisations*`, `TestAdminRegistration`, class and model unit tests.
|
||||
- Docs: `12.1-SECURITY-REVIEW.md`, the validated `12.1-VALIDATION.md`.
|
||||
|
||||
## Planner decisions recorded for this plan
|
||||
|
||||
- **Security review author.** CONTEXT asks for the security-review agent. When the executor's runtime can spawn it, its findings are recorded in 12.1-SECURITY-REVIEW.md; otherwise the review is self-performed by the executor against code and tests and the reviewer line says so (the 08-10 and Phase 12 precedent). The orchestrator may additionally run `/gsd-secure-phase 12.1` after execution.
|
||||
- **Removal stage scope.** Every high or critical threat with disposition mitigate gets a removal row; medium and low threats are covered by named tests only. The removal stage edits tracked source while it runs and is therefore not part of `--all`. The two D-30 threats (T-12.1-38 critical, T-12.1-39 high) each have a row.
|
||||
- **Publication (plan-check revision).** Of the two routes offered at the plan check, the plugin push moved here: it is the last step of Task 3, after the security review, and it runs only when `git ls-remote --tags origin v0.1.3` lists the framework tag. Plan 04 Task 4 and Task 2 of this plan therefore bump the application's pointer in local commits and push nothing. When a condition does not hold the push is skipped and recorded as pending; it is never forced.
|
||||
- **Coverage floor.** 80 percent per listed package, as in Phase 12.
|
||||
- **Manual checks.** The visual checks of the preview screen, row-state styling and the permission editor are one end-of-phase human-check item on Task 3 (`workflow.human_verify_mode` default).
|
||||
- **Spec-less probe fallback: skipped** (no requirement IDs, no SPEC.md); no probe predicates were generated.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: The T-12-18 guard and every other mitigated threat of the phase are pinned by one named threat test per repository, run by a gate stage that fails closed</name>
|
||||
<reversibility rating="reversible">Tests and a new script; no production code changes unless a test exposes a defect.</reversibility>
|
||||
<files>scripts/check-phase12.1.sh, modules/cabana/phase121_threats_test.go, ../fonoteka.go/plugins/golem15/user/phase121_security_test.go</files>
|
||||
<read_first>scripts/check-phase12.sh (whole script), scripts/check-phase12.2.sh (whole script), .planning/phases/12.1-user-plugin-admin-screens/12.1-01-PLAN.md, 12.1-02-PLAN.md, 12.1-03-PLAN.md and 12.1-04-PLAN.md (the threat_model blocks), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("T-12-18: Every users_groups Write Path"; Security Domain), .planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/phase121_form_test.go, ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go (TestAdminPrivilegedMember), .planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md (D-30), ../fonoteka.go/plugins/golem15/fonoteka/phase12_security_test.go (TestPhase12Threats layout)</read_first>
|
||||
<action>Per D-04 to D-08, D-30 and the Security Domain of RESEARCH. Repos: summercms.go (script and cabana test) and sm-user-plugin (plugin test).
|
||||
|
||||
(1) modules/cabana/phase121_threats_test.go: `TestPhase121Threats` with one subtest per mitigated framework threat, named by its id (T-12.1-01 to T-12.1-16), each asserting the protection through the admin API on the acme.roster fixture: 01 an id outside the list scope never reaches Run and a mixed selection is 409 with nothing changed; 02 an undeclared and an unregistered action answer 404 and a declared one without its permission answers 403 and is absent from the schema; 03 both action routes refuse a cookie request without the Ajax header; 04 a record action on an out-of-scope record is 404 and on a non-applicable record 409; 05 a bulk action failing on the last row leaves the first rows unchanged; 06 a plain hook error answers the generic 500 body with no error text while a ForbiddenError answers its message; 07 an unknown row state is not sent; 08 one log line per action run with the admin id and no record contents (captured with a slog handler); 09 a virtual field value is never stored or returned; 10 a password never appears in any response; 11 a protected foreign key is read-only without the opt-in; 12 a locked relation id cannot be added or removed on create or update; 13 an unknown permission code, an out-of-range value and a changed locked code are refused; 14 a preview-only field cannot be written; 15 a status partial that carries markup is served as nodes with only allowlisted attributes; 16 is covered in the SPA (winterUrl) and referenced by a comment naming the vitest case.
|
||||
|
||||
(2) ../fonoteka.go/plugins/golem15/user/phase121_security_test.go: `TestPhase121Threats` with one subtest per mitigated plugin threat (T-12.1-18 to T-12.1-25, T-12.1-27 to T-12.1-31, T-12.1-34, T-12.1-38 and T-12.1-39), using the plan 03 harness: 18 every Users, User Groups and Organisations route, bulk action and record action answers 403 without its permission; 19 no admin response carries a password, hash or reset or activation code; 20 a body carrying is_activated, permissions as a scalar, organisation_id or password as plain columns changes none of them; 21 an admin password reset stamps tokens_valid_after and an older token is refused; 22 exactly one invitation per created user and none on update; 23 a permanent delete leaves no user_throttle, users_groups or system_files row and is refused without the permission; 24 no user API payload carries last_seen, timestamps, permissions other than null or a non-empty groups list; 25 a failing last_seen write leaves login and refresh at 200; 27 the resolver's override, exactly-1 and wildcard rules; 28 (T-12-18 revisited, cited by that id in the subtest's comment) the full add, remove and create matrix with and without golem15.users.manage_privileged_groups, including a crafted body that changes a privileged membership together with other fields, and the answer of classes.HasGroupCode before and after; 29 the four privileged-code cases on the group form; 30 users_groups is byte-identical before and after every bulk action, every record action, every organisation members link and unlink, and no relation route for groups exists on the users or groups controller; 31 an overridden privileged list and a group with a NULL code; 34 the application's rule that groups is never serialized, asserted on the plugin's own payload builder; 38 (D-30, takeover of a privileged-group member) with the membership given through the user form's groups field by an admin who holds the permission: an admin holding only golem15.users.access_users who changes that member's email, submits a password for that member, or does both in one body that also changes the name, gets 403 each time with details on the refused fields, and afterwards the email, the name, the password hash and tokens_valid_after are byte-identical to before, a login with the old password succeeds and a login with the attempted password fails; a name-only update by that admin answers 200; the same email change and password reset by an admin who also holds golem15.users.manage_privileged_groups answer 200; after the membership is removed the admin without the permission may change the email; 39 (D-30, permanent delete) the form delete of a privileged-group member and a bulk delete that contains that member together with ordinary users each answer 403 for the admin without the permission, and every selected user and its users_groups, user_throttle and system_files rows remain; both succeed with the permission. The assertions of 38 and 39 are written so that each fails when its check is removed (the removal stage of Task 3 removes them one at a time).
|
||||
|
||||
(3) scripts/check-phase12.1.sh, first stages (the remaining stages are added in Task 3): the header contract and `set -euo pipefail` of check-phase12.2.sh; ROOT and `APP="${PHASE121_APP:-$ROOT/../fonoteka.go}"`; the go test -json detector of check-phase12.sh (exit codes for fail or build failure, skip, zero tests or "no tests to run", non-JSON output, a required name that did not pass); `--security` running the framework's named tests by prefix (the TestPhase121Threats, TestBulkAction, TestRecordAction, TestRowState, TestForbidden, TestSoftDeletedRecord, TestPreview, TestPasswordField, TestVirtualFields, TestFormRules, TestPermissionEditor, TestRelationLock, TestWritableForeignKey, TestInvisibleColumn and TestFilterOptionsController families in ./modules/cabana) and the plugin's (TestPhase121Threats, TestAdminPrivilegedGroups, TestAdminPrivilegedMember, TestAdminUserGroupsField, TestAdminUserActions, TestAdminUserForceDelete, TestAdminUserPassword, TestAdminUserInvite, TestAdminAvatarSharedWithAPI, TestAdminGroups, TestAdminOrganisations, TestAdminOrganisationMembers, TestLastSeen in ./plugins/golem15/user, and TestMergedPermissions, TestPermissionSetScan in its classes package) inside the application workspace, each prefix needing at least one passing top-level test and any skip refusing; `--self-test` proving the detector fails closed on planted inputs (a failing test, a skipped test, a run with no tests, non-JSON output, a missing required prefix, a build failure); a usage text listing every stage name of the Artifacts section. Make the script executable.
|
||||
|
||||
(4) If a subtest exposes a real defect, fix it in the owning repository in its own commit naming the threat id, then re-run.</action>
|
||||
<verify>
|
||||
<automated>scripts/check-phase12.1.sh --self-test && scripts/check-phase12.1.sh --security && go test ./modules/cabana -run '^TestPhase121Threats$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestPhase121Threats$' -count=1 -v</automated>
|
||||
<fails_when>Any command exits non-zero; the gate prints a line starting with "refuse:"; either named run lacks "--- PASS: TestPhase121Threats", prints "--- SKIP", or prints "no tests to run".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -x scripts/check-phase12.1.sh` succeeds and `scripts/check-phase12.1.sh --self-test` exits 0.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestPhase121Threats$' -count=1 -v` prints a "--- PASS" line for each of the subtests T-12.1-28, T-12.1-38 and T-12.1-39 and no "--- SKIP" line.
|
||||
- `go test ./modules/cabana -run '^TestPhase121Threats$' -count=1 -v` prints a "--- PASS" line for each of the subtests T-12.1-01 to T-12.1-15.
|
||||
- `grep -c 'T-12-18' ../fonoteka.go/plugins/golem15/user/phase121_security_test.go` prints at least 1 (the revisited threat is cited by its original id).
|
||||
- Running `scripts/check-phase12.1.sh --security` with one named plugin test temporarily renamed makes it exit non-zero with a "refuse: missing named test" line (checked once by hand and recorded in the summary; the rename is reverted).
|
||||
</acceptance_criteria>
|
||||
<done>One command proves, fail-closed, that the privileged-group guard and every other mitigated threat of the phase hold in both repositories.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Every Go behaviour of the phase has a unit test: the framework contracts and the user plugin's screens, classes, models and migrations</name>
|
||||
<reversibility rating="reversible">Tests only, in local commits; nothing is pushed by this task.</reversibility>
|
||||
<files>modules/pact/capabilities_test.go, modules/cabana/phase121_bulk_test.go, modules/cabana/phase121_record_test.go, modules/cabana/phase121_rowstate_test.go, modules/cabana/phase121_forbidden_test.go, modules/cabana/phase121_preview_test.go, modules/cabana/phase121_fields_test.go, modules/cabana/phase121_permission_test.go, modules/cabana/phase121_relation_lock_test.go, modules/cabana/phase121_list_test.go, modules/cabana/phase121_schema_boot_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_edge_test.go, ../fonoteka.go/plugins/golem15/user/admin_groups_edge_test.go, ../fonoteka.go/plugins/golem15/user/admin_organisations_edge_test.go, ../fonoteka.go/plugins/golem15/user/admin_registration_test.go, ../fonoteka.go/plugins/golem15/user/classes/admin_actions_test.go, ../fonoteka.go/plugins/golem15/user/classes/privileged_test.go, ../fonoteka.go/plugins/golem15/user/classes/permissions_test.go, ../fonoteka.go/plugins/golem15/user/classes/last_seen_test.go, ../fonoteka.go/plugins/golem15/user/models/permission_set_test.go, ../fonoteka.go/plugins/golem15/user/models/slug_test.go, ../fonoteka.go/plugins/golem15/user/models/admin_models_test.go, ../fonoteka.go/plugins/golem15/user/updates/admin_columns_test.go, ../fonoteka.go/plugins/golem15/user</files>
|
||||
<behavior>
|
||||
- Bulk action: an empty id list is 422; duplicate ids are de-duplicated; records reach Run ordered by primary key; a wholly absent selection answers affected 0 without calling Run; a Run error rolls every row back; two concurrent runs over the same rows do not deadlock and both end consistent; the result message is localized.
|
||||
- Record action: body with record_id or values is 422; trailing tokens and unknown keys are 422; Applies error is the generic 500; meta.actions keeps declared order and omits denied and non-applicable actions.
|
||||
- Row state: the hook result with a wrong length fails the list with the generic 500; duplicates collapse; order is deleted, negative, disabled; the meta key is absent without states.
|
||||
- Every boot error introduced by plans 01 and 02 has a test asserting its message names the plugin, the controller and the file.
|
||||
- Permission editor: radio accepts 1 and -1 and treats 0 as absent; checkbox accepts 1 only; stored codes that are not offered survive; a provider error is the generic 500.
|
||||
- Plugin: each class function is tested against a real Postgres for its changed-row count and idempotency; PermissionSet round-trips; Slugify matches the SPA rule on a shared table of inputs.
|
||||
- Plugin, D-30: IsPrivilegedMember is false for a user in no group, in an ordinary group only and in a group without a code, true for a member of a listed group, and follows an overridden list; the guard lets a superuser through and answers a failed membership query as the opaque 500 with nothing changed.
|
||||
</behavior>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("Validation Architecture"; "Common Pitfalls"), .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md, the four SUMMARY files of plans 01 to 04, modules/cabana/bulk_test.go, modules/cabana/crud_lifecycle_test.go, modules/cabana/phase101_actions_test.go, modules/cabana/phase101_schema_test.go, modules/cabana/relation_field_test.go, modules/cabana/phase121_fixture_test.go, modules/cabana/phase121_actions_test.go, modules/cabana/phase121_form_test.go, modules/pact/capabilities_test.go, ../fonoteka.go/plugins/golem15/user/admin_users_test.go, ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go, ../fonoteka.go/plugins/golem15/user/admin_groups_test.go, ../fonoteka.go/plugins/golem15/user/admin_organisations_test.go, ../fonoteka.go/plugins/golem15/user/classes/throttle_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups_test.go, ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go, scripts/check-phase12.sh (coverage_report, cover_profile)</read_first>
|
||||
<action>Per ROADMAP success criterion 5 and the RESEARCH "Success Criteria to Test Map". Write each test before any fix it motivates; the smoke tests of plans 01 to 04 stay.
|
||||
|
||||
(1) Framework (summercms.go, neutral acme names). modules/pact/capabilities_test.go: the new types compile against a sample controller and the RowState constants hold their three values. phase121_bulk_test.go, mirroring bulk_test.go: `TestBulkActionEmpty`, `TestBulkActionDuplicates`, `TestBulkActionOrder`, `TestBulkActionAbsent`, `TestBulkActionPartial`, `TestBulkActionScope`, `TestBulkActionRollback`, `TestBulkActionConcurrent`, `TestBulkActionPermissions`, `TestBulkActionUndeclared`, `TestBulkActionCSRF`, `TestBulkActionMessageLocalized`, `TestBulkActionBodyCap`, and `TestListSchemaBulkActionsFiltered` (per-principal list, the cached schema unchanged after a filtered request). phase121_record_test.go: `TestRecordActionScope`, `TestRecordActionApplies`, `TestRecordActionBody`, `TestRecordActionPermissions`, `TestRecordActionOffered`, `TestRecordActionRollback`, `TestRecordActionAppliesError`. phase121_rowstate_test.go: `TestRowStateOrder`, `TestRowStateUnknownDropped`, `TestRowStateLengthMismatch`, `TestRowStateHookError`, `TestRowStateAbsentKey`, `TestRowStateOncePerPage`. phase121_forbidden_test.go: `TestForbiddenFromEveryHook` (before and after create, update and delete, bulk delete, relation link and child hooks), `TestForbiddenLocalized`, `TestForbiddenRollsBack`, `TestForbiddenEmptyMessage`. phase121_preview_test.go: `TestPreviewSchema`, `TestPreviewFieldNeverWritten`, `TestPreviewHeaderPartialScoped`, `TestPreviewMessagesDefaults`. phase121_fields_test.go: `TestPasswordFieldNeverProjected`, `TestVirtualFieldsContext`, `TestVirtualFieldsNested`, `TestFormRulesReplaceModelRules`, `TestFormRulesRequiredMerge`, `TestPresetSchemaShapes`. phase121_permission_test.go: `TestPermissionEditorModes`, `TestPermissionEditorUnknownCode`, `TestPermissionEditorLocked`, `TestPermissionEditorKeepsUnoffered`, `TestPermissionEditorOptionsPerRequest`, `TestPermissionEditorProviderError`. phase121_relation_lock_test.go: `TestRelationLockCreate`, `TestRelationLockUpdate`, `TestRelationLockBelongsTo`, `TestRelationLockAbsentField`, `TestRelationLockNoProvider`, `TestWritableForeignKeyOptIn`, `TestWritableForeignKeyScope`. phase121_list_test.go: `TestInvisibleColumnSearchAndRows`, `TestFilterOptionsControllerFirst`, `TestFilterOptionsModelFallback`. phase121_schema_boot_test.go: `TestPhase121BootErrors`, a table of every boot error message plans 01 and 02 introduced (bulkActions without showCheckboxes, unregistered or unlabelled bulk and record actions, a scalar action list, a reserved or duplicate action name, recordActions without preview, a null preview, a password field outside FormVirtualFields, a virtual field of a wrong type, an unsupported preset type, preset on a non-text field, permissioneditor without mode or without the provider, mode on another type, WritableForeignKey on belongsToMany), each asserting the plugin id, controller id and file in the message.
|
||||
|
||||
(2) Plugin (sm-user-plugin). classes/admin_actions_test.go: every function of classes/admin_actions.go for its changed-row count, idempotency, the ban semantics across several throttle rows and a NULL-IP row, the suspension window as a pure read, and the cleanup of ForceDeleteCleanup. classes/privileged_test.go: default list, overridden list, blank entries, case sensitivity, a NULL code, PrivilegedGroupIDs, and `TestIsPrivilegedMember` (a user in no group, in an ordinary group only, in a group without a code, in a listed group, and under an overridden list; D-30). classes/permissions_test.go: extend the table (several groups, numeric strings in stored JSON, an empty group set, a code present only at user level). classes/last_seen_test.go: the five-minute boundary on both sides and a deactivated user. models/permission_set_test.go, models/slug_test.go (the table of inputs the SPA preset test uses: accents, punctuation runs, leading and trailing separators, an empty string, a long text), models/admin_models_test.go (Fillable and Rules of UserGroup and Organisation, MorphName, AttachRelations names, BeforeValidate, FilterScopes, JSON marshalling of User without the new fields). admin_registration_test.go `TestAdminRegistration`: the five permission codes with their tab, the navigation tree with three side items and their permissions, every embedded admin file is readable from AdminFS, every lang key the YAML and the controllers name resolves in en and pl. admin_users_edge_test.go, admin_groups_edge_test.go, admin_organisations_edge_test.go: the remaining branches of the three controllers (unknown partial name, an unknown filter scope, options for another field, a group update that keeps a privileged code, an organisation delete without members, a members link of a deactivated user, search and sort on each list, and the remaining branches of the D-30 guard: a superuser passes, and a failed membership query is the opaque 500 with nothing changed). updates/admin_columns_test.go: each migration up, down and up again, and that the frontend permissions code is unique.
|
||||
|
||||
(3) Measure coverage with go test -coverprofile over modules/pact and modules/cabana and over the plugin's packages with -coverpkg, as check-phase12.sh does; add tests until each package is at 80 percent or more; record the numbers in the summary.
|
||||
|
||||
(4) Commits: framework tests in summercms.go; plugin tests inside the plugin checkout; then bump the submodule pointer in fonoteka.go as its own local commit. Push nothing in this task: the plugin is pushed in the last step of Task 3, after the security review and only when the framework tag v0.1.3 is on origin, and fonoteka.go is not pushed by this plan. Read the plugin's remote head with `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` at the start and at the end of the task and record both shas in the summary.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/pact/... ./modules/cabana/... -count=1 && go test ./modules/cabana -run '^(TestBulkAction|TestRecordAction|TestRowState|TestForbidden|TestPreview|TestPasswordField|TestVirtualFields|TestFormRules|TestPreset|TestPermissionEditor|TestRelationLock|TestWritableForeignKey|TestInvisibleColumn|TestFilterOptions|TestPhase121)' -count=1 -v && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 -v && go -C ../fonoteka.go test ./... -count=1 && test -z "$(git -C ../fonoteka.go/plugins/golem15/user status --short)" && test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD)" && git -C ../fonoteka.go/plugins/golem15/user fetch --quiet origin && test -n "$(git ls-remote --tags origin v0.1.3)" -o "$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD)" != "0"</automated>
|
||||
<fails_when>Any command exits non-zero; a run prints a line starting with "FAIL", a "--- SKIP" line for a test of this phase, or "no tests to run"; the named cabana run lacks "--- PASS: TestPhase121BootErrors" or "--- PASS: TestBulkActionConcurrent"; the plugin checkout has uncommitted changes; the application's pointer differs from the plugin head; the last term exits 1, which means the plugin checkout has no commit ahead of its freshly fetched origin (its head is published) while the framework tag v0.1.3 is not on the framework's origin.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test ./modules/cabana -run '^TestPhase121BootErrors$' -count=1 -v` prints "--- PASS: TestPhase121BootErrors".
|
||||
- `go test ./modules/cabana -run '^TestBulkAction' -count=1 -v` prints "--- PASS" lines for at least the cases Empty, Duplicates, Order, Absent, Partial, Scope, Rollback, Concurrent, Permissions, Undeclared and CSRF.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminRegistration$' -count=1 -v` prints "--- PASS: TestAdminRegistration".
|
||||
- The coverage numbers recorded in the summary are at least 80 percent for modules/pact, modules/cabana and each of the plugin's packages root, classes, controllers, models and updates.
|
||||
- `go vet ./... && go test ./... -count=1` exits 0 in summercms.go and `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` exits 0 at the task's commits.
|
||||
- `git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user` equals `git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD` (the pointer is bumped in a local commit), and the sha printed by `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` at the end of the task equals the sha read at its start (both are in the summary): this task pushed nothing.
|
||||
- `go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^TestIsPrivilegedMember' -count=1 -v` prints a "--- PASS" line and does not print "no tests to run" (D-30).
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour the phase added, in the framework and in the user plugin, is covered by a unit or integration test, at or above the coverage floor.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The SPA behaviours and UI backstops are unit-tested, one gate script proves the whole phase, the security review and the validation file sign it off, and only then is the plugin published or its push recorded as pending</name>
|
||||
<reversibility rating="costly">Tests, a script and planning documents are reversible. The last step pushes sm-user-plugin master, which publishes the additive migrations and the permission codes to every project that mounts the shared plugin (D-04 and D-15, user-confirmed); it therefore runs only after the security review and only when the framework tag v0.1.3 is on origin, and is skipped otherwise.</reversibility>
|
||||
<files>admin/tests/list/BulkActionsMenu.test.ts, admin/tests/list/RowStateBadges.test.ts, admin/tests/list/ListToolbar.test.ts, admin/tests/list/DataTable.test.ts, admin/tests/list/ListView.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/form/PreviewView.test.ts, admin/tests/form/PreviewField.test.ts, admin/tests/form/PermissionEditorField.test.ts, admin/tests/form/PasswordField.test.ts, admin/tests/form/RelationField.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/FormErrorBanner.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/registry.test.ts, admin/tests/app/winterUrl.test.ts, admin/tests/app/router.test.ts, scripts/check-phase12.1.sh, .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md, .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md, ../fonoteka.go/plugins/golem15/user</files>
|
||||
<read_first>.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S1 to S7 and "UI Considerations": every resolved row, the five backstop rows), .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md, the five plans' threat_model blocks, the SUMMARY files of plans 01 to 04, scripts/check-phase12.sh (removal_table, removal_harness, coverage_report, evidence_check, run_self_test), scripts/check-phase12.2.sh (spa, openapi, dist, docs, hygiene, app stages; PHASE_FILES; HYGIENE_DOCS), .planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md, .planning/phases/12-p-ytarium-api-collections-and-albums/12-VALIDATION.md, admin/tests/list/ListToolbar.test.ts, admin/tests/form/RelationField.test.ts, admin/tests/form/DatepickerField.test.ts, admin/tests/app/winterUrl.test.ts, admin/tests/app/router.test.ts, admin/tests/fixtures/typed.ts, admin/tests/helpers.ts, admin/tests/smoke/actions.smoke.test.ts, admin/tests/smoke/preview.smoke.test.ts, admin/tests/smoke/seams.smoke.test.ts</read_first>
|
||||
<action>Per the UI-SPEC "UI Considerations" rows and the RESEARCH "Validation Architecture". Repo: summercms.go (SPA tests and script in code commits; the two planning documents in a separate commit).
|
||||
|
||||
(1) SPA unit tests in the existing vitest style (describe titles carry the decision id, selectors by data attributes, typed fixtures). BulkActionsMenu and ListToolbar and ListView (D-09, S1): no menu with zero permitted actions; a disabled trigger keeps its label; items in declared order; a long label wraps; the confirm uses the action's confirm text or the default with :action and :count; the dialog stays busy until the POST settles; the three failure rows of the S1 table; and the backstop "focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel". RowStateBadges and DataTable (D-12, S4): each state's badge and text classes, combined states, the fixed order, the data-row-states attribute, an unchanged row without states, and the backstop "a row state outside the fixed set renders no badge and no class". RecordActions (D-10, S2): rendering order, the confirm, the busy state, the done, stale and gone events, the 403 toast. PreviewView and PreviewField (D-11, S3): each row of the PreviewField table, the empty dash, tabs without preview fields hidden, the hint skeleton and keep-previous behaviour, the footer with zero, one and several actions, the load failure. winterUrl and router: the backstop "mapWinterUrl maps preview/:id to the preview route, and opening the preview route of a form without a preview replaces it with the record route", plus rejection of a foreign controller and of a non-numeric id, and CONTROLLER_ROUTES containing preview. PermissionEditorField (D-16, S5): sections by tab, the Other section, the empty state, the invalid border, read-only rendering, the locked row, and the backstop "radio mode emits 1 and -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent". RelationField (D-07, S6): the locked chip, the note line, and the backstop "a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace". FormErrorBanner and FormView (S6 forbidden save): the banner with and without a server message, details on fields with focus on the first, values and dirty state kept, the banner cleared on the next save, a 403 on delete as a toast. PasswordField, formState and FormView (S7): empty on load, the toggle and its aria-pressed, an empty password left out on update and sent on create, both fields cleared after a save; preset on create only, stopping at the first manual edit, the slug rule on the same table of inputs the plugin's slug test uses. registry: the two new types and their set memberships. DataTable: invisible columns are not rendered.
|
||||
|
||||
(2) Complete scripts/check-phase12.1.sh with the remaining stages: `--go` (go vet and go test for the framework, each through the detector), `--spa` (typecheck and vitest, refusing "No test files found"), `--openapi` and `--dist` (the two existing check scripts), `--docs` (TestDocsTree and docs:build --check), `--hygiene` (no consuming-application name in the framework files this phase added or changed, listed in PHASE_FILES, nor in the module READMEs, docs and admin/src; the pattern is the APP_NAMES expression of check-phase12.2.sh), `--app` (go vet and the full go test of the application workspace through the detector, including the schema parity and the user API parity tests), `--coverage` (the 80 percent floor per listed package with its number printed), `--removal` (the anchor-exact mutation harness of check-phase12.sh with one row per high or critical mitigated threat: the lockScoped call in BulkAction, the shared own-permission check, requireAjax on the two routes, loadRecord and the Applies check in RecordAction, the virtual-field skip in BindWritableFields, the password exclusion from projection, the WritableForeignKey condition, the checkRelationLocks call, the option-code check of the permission editor, the Users controller's RequiredPermissions, json "-" on the new user fields, the users controller's AdminRelationLocks, the group code check in the before-hook, the privileged-member check at the top of the users controller's FormBeforeUpdate (D-30; the subtest T-12.1-38 of the plugin's TestPhase121Threats must then fail) and the privileged-member check in its FormBeforeDelete (D-30; the subtest T-12.1-39 must then fail); each row names the test that must then fail on an assertion; a dirty file is refused; every file is restored byte for byte and compared with cmp; not part of --all), `--evidence` (every threat id found in the threat_model blocks of the five plans appears in 12.1-SECURITY-REVIEW.md with a test name, and 12.1-VALIDATION.md has no pending or TBD row and says nyquist_compliant true), and `--all` (every stage except removal, one PASS or FAIL line per stage, stopping at the first failure). Extend `--self-test` so each new detector (coverage below the floor, a hygiene hit, an evidence gap, a removal row whose test still passes) is proven on planted input.
|
||||
|
||||
(3) Run `scripts/check-phase12.1.sh --removal` and then `scripts/check-phase12.1.sh --all`; fix what they expose (a framework fix after the tag is its own commit with its documentation duties and is recorded as needing a follow-up tag).
|
||||
|
||||
(4) Planning documents, in a separate commit. 12.1-SECURITY-REVIEW.md in the format of the Phase 12 review: frontmatter (phase, reviewed date, reviewer per the decision recorded above, threats_open, gate, removal_harness), a table of T-12.1-01 to T-12.1-40 and T-12.1-SC with category, component, severity, disposition, the production mitigation with file and function, the test or gate stage, the observed result and the residual risk; a section on T-12-18 (the eight write paths of RESEARCH and how each is guarded or accepted); a section on D-30 (T-12.1-38 and T-12.1-39: the three guarded operations on a privileged-group member, the plugin commit in which the guard landed, the removal-stage result for both checks, and the boundary: which operations on such a member still need only golem15.users.access_users and why); the accepted risks T-12.1-26 and T-12.1-32; a list of fixes made during the review. 12.1-VALIDATION.md: replace the seeded map rows with final rows keyed by real task ids (12.1-01-T1 to 12.1-05-T3), among them the D-30 row (T-12.1-38 and T-12.1-39, TestAdminPrivilegedMember and the two threat subtests), the exact commands the plans ran, file-exists ticks and statuses; tick the Wave 0 items; record the measured run times and the feedback latency; tick the sign-off list; set wave_0_complete true, nyquist_compliant true, status validated, the validated date and the gate command.
|
||||
|
||||
(5) Publication of the shared plugin (threat T-12.1-40), last, and only after steps (1) to (4) are committed and `scripts/check-phase12.1.sh --all` has passed on the final heads. First read the plugin's remote head with `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` and note it. If a fix made in this task moved the plugin head, bump the application's pointer in a local commit. Then check four conditions: (a) the gate passed; (b) 12.1-SECURITY-REVIEW.md says threats_open 0; (c) `git ls-remote --tags origin v0.1.3`, run in summercms.go, lists the framework tag; (d) no framework production code changed after the tag, which holds when `git diff --name-only v0.1.3 HEAD -- modules cmd admin/src` lists nothing but Go test files and files under a testdata directory. When all four hold, push master of the plugin checkout to its origin (plain git or the user's submodule tool `ssu`), never with force, and confirm that the plugin's remote head now equals its local head. When any condition does not hold, do not push: record the line "pending: push sm-user-plugin" in the summary and in STATE.md together with the condition that failed and what has to happen first (for (c): the push of framework master and v0.1.3 that plan 02 left pending; for (d): the follow-up framework tag on origin). A push refused for authentication or network reasons is reported as an authentication gate and recorded as pending in the same way. fonoteka.go is not pushed: record "push fonoteka.go" as the user's step once the plugin head is on its origin.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-phase12.1.sh --self-test && scripts/check-phase12.1.sh --removal && scripts/check-phase12.1.sh --all && test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD)" && git -C ../fonoteka.go/plugins/golem15/user fetch --quiet origin && test -n "$(git ls-remote --tags origin v0.1.3)" -o "$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD)" != "0"</automated>
|
||||
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; the gate prints a line starting with "refuse:" or a "FAIL" stage line; a removal row reports that its named test still passed; the coverage stage prints a package below 80; the evidence stage reports a threat id without a test or a pending row; the application's pointer differs from the plugin head; the last term exits 1, which means the plugin checkout has no commit ahead of its freshly fetched origin (its head is published) while the framework tag v0.1.3 is not on the framework's origin.</fails_when>
|
||||
<human-check>
|
||||
<test>Start the application against the tagged framework, sign in as a backend admin holding golem15.users.access_users and golem15.users.access_groups but not golem15.users.manage_privileged_groups, and walk the three screens in light and dark mode: filter and search Users, open a banned and a deactivated user's preview, run Activate, Unban and a bulk Ban, create a user with an invitation, open the Permissions tab, try to add the admin group to a user, edit a group's permissions, add and remove an organisation member. Then, still as that admin, open a user who is in the admin group: change the name only and save; change the email and save; enter a new password and save; press Delete; and select that user together with another one in the list and use the bulk delete.</test>
|
||||
<expected>The screens match the UI-SPEC (row-state badges with text, one status callout on the preview, record actions before the single primary edit button, the segmented permission control, the locked admin group with its note, the forbidden banner when the locked group is forced through a crafted request), and nothing in the app's own user payloads changed. For the user in the admin group (D-30): the name-only save succeeds; the email save and the password save each show the forbidden banner with the marked field, keep what was typed and save nothing; Delete and the bulk delete each show a danger toast and delete nobody.</expected>
|
||||
<why_human>Visual fit with the design system in both themes and the end-to-end feel of the screens cannot be asserted by unit tests.</why_human>
|
||||
</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `scripts/check-phase12.1.sh --all` exits 0 and prints one PASS line for each of the stages go, security, coverage, spa, openapi, dist, docs, hygiene, app and evidence.
|
||||
- `scripts/check-phase12.1.sh --removal` exits 0, and `git status --short` in summercms.go and in the plugin checkout prints no modified source file afterwards.
|
||||
- `npm --prefix admin test` lists the five backstop cases as passed (their test titles contain the word backstop).
|
||||
- `grep -c 'nyquist_compliant: true' .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md` prints 1 and `grep -c '| TBD |' .planning/phases/12.1-user-plugin-admin-screens/12.1-VALIDATION.md` prints 0.
|
||||
- Every threat id of the five plans appears in the review: the sorted unique output of `grep -ohE 'T-12\.1-(SC|[0-9]{2})' .planning/phases/12.1-user-plugin-admin-screens/12.1-0*-PLAN.md` (41 ids: T-12.1-01 to T-12.1-40 and T-12.1-SC) equals that of the same grep over 12.1-SECURITY-REVIEW.md, and `grep -c 'T-12-18' .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md` prints at least 1.
|
||||
- `grep -c 'threats_open: 0' .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md` prints 1.
|
||||
- `scripts/check-phase12.1.sh --removal` prints one row for the privileged-member check in FormBeforeUpdate and one for the check in FormBeforeDelete, each reporting that the subtest T-12.1-38 or T-12.1-39 failed with the check removed and that the file was restored (D-30), and `grep -c 'D-30' .planning/phases/12.1-user-plugin-admin-screens/12.1-SECURITY-REVIEW.md` prints at least 1.
|
||||
- Publication is recorded in the summary: for each of the conditions (a) to (d) of step (5) whether it held, and either the pushed plugin head sha (then, after `git -C ../fonoteka.go/plugins/golem15/user fetch origin`, `git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD` prints 0) or the line "pending: push sm-user-plugin" with the condition that failed (then the sha printed by `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` is the one noted at the start of step (5)).
|
||||
</acceptance_criteria>
|
||||
<done>The phase is closed by evidence: the SPA and its UI backstops are unit-tested, one gate fails closed on any regression in either repository, the security review ties each threat to a test, the validation file is signed off, and the plugin is either published after the review on a published framework tag or its push is recorded as pending.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test evidence → release decision | The gate's result is what the phase is verified against |
|
||||
| Framework tree → consumers | Test fixtures and gate output must not name a consuming application |
|
||||
| Plugin repository → host applications | A push of sm-user-plugin publishes its migrations, permission codes and admin screens to every project that mounts the shared plugin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.1-35 | Repudiation | a gate that passes without measuring (zero tests, skips, a filter that matches nothing) | medium | mitigate | The go test -json detector refuses a failure, a skip, zero tests, non-JSON output and a missing named test; `--self-test` proves each on planted input (Tasks 1, 3). |
|
||||
| T-12.1-36 | Information Disclosure | a consuming-application name in framework fixtures, tests, docs or gate output | low | mitigate | The hygiene stage scans the phase's framework files, the module READMEs, docs and admin/src (Task 3). |
|
||||
| T-12.1-37 | Tampering | a mitigation silently removed by a later change | medium | mitigate | `TestPhase121Threats` in both repositories with one subtest per threat, the security stage's required name prefixes, and the removal harness proving each high or critical protection is load-bearing (Tasks 1, 3). |
|
||||
| T-12.1-40 | Tampering | the shared plugin is published before the security review, or while the framework contract it builds on (tag v0.1.3, or a later framework fix) is not published | medium | mitigate | One push point for sm-user-plugin in the whole phase: step (5) of Task 3, after the gate and the review, and only when `git ls-remote --tags origin v0.1.3` lists the tag and no framework production code changed after it; otherwise the push is skipped and recorded as pending, never forced. Plans 04 and 05 keep the application's pointer on local commits, and each of their verify commands fails when the plugin's head is on its origin while the tag is not (Task 3; plan 04 Task 4). |
|
||||
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan; tests use only modules and packages already present. Any need for one stops at a blocking human checkpoint. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `scripts/check-phase12.1.sh --self-test && scripts/check-phase12.1.sh --all` exits 0; `scripts/check-phase12.1.sh --removal` exits 0 and leaves both working trees clean.
|
||||
- `go vet ./... && go test ./... -count=1` green in summercms.go; `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green.
|
||||
- The application's pointer equals the plugin head. The plugin's head is on its origin only when the framework tag v0.1.3 is on origin; otherwise the summary and STATE.md record "pending: push sm-user-plugin".
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ROADMAP success criterion 5 is met: the new code of both repositories has unit tests at or above the 80 percent floor, delivered in this last plan.
|
||||
- The gate, the security review (zero open high or critical threats) and the validated validation file are in place before `/gsd-verify-work`.
|
||||
- The takeover guard of D-30 is pinned by a threat subtest and a removal row for each of its two checks.
|
||||
- The plugin is published only after the security review and only on a published framework tag, or its push is recorded as pending.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-05-SUMMARY.md` when done. Record the coverage numbers, the gate output, any fix made during the review (and whether a follow-up framework tag is needed), the outcome of the publication step (the pushed plugin head sha, or "pending: push sm-user-plugin" with the condition that failed), and the pending "push fonoteka.go" step.
|
||||
</output>
|
||||
@@ -60,6 +60,9 @@ Out of scope: impersonating a user, the guest concept and convert-guest, MailBlo
|
||||
- **D-28:** G5 is solved with an optional controller interface that returns the rule set per operation, not with a wrapper record type in the plugin.
|
||||
- **D-29:** The research recommendations for the remaining open questions are accepted: User Groups keep the standard form delete, guarded per D-06, with pivot cleanup; `last_seen` is written on login and on refresh, at most once per five minutes, and a failed write never fails auth; a user created in the admin starts not activated and only the `activate` actions set `is_activated`; bulk `activate` skips users that are already active and reports the affected count.
|
||||
|
||||
### Plan-check decision (confirmed 2026-10-04)
|
||||
- **D-30:** A user who is a member of a privileged group (D-05) is protected against takeover. Changing that user's password or email, and permanently deleting them (form delete and bulk delete), additionally requires the D-04 permission. Without it the request is refused with a forbidden error and changes nothing (no partial save; a bulk delete containing such a user is refused as a whole). This deliberately differs from PHP, where `access_users` alone is enough. — **Reversibility:** cheap — a server-side check in the plugin; no contract or schema change.
|
||||
|
||||
### Claude's Discretion
|
||||
- The code of the extra permission in D-04 (for example `golem15.users.manage_privileged_groups`), its label and tab, and the config key name for the privileged list.
|
||||
- YAML keys and Go interface names for bulk actions, record actions, preview and row state, provided they follow the existing fail-loud rules (unknown keys and unregistered actions are boot errors), sit under the `{prefix}/api/v1/{vendor}/{plugin}/{controller}/...` scheme, use `requireAjax` on writes and carry swag annotations.
|
||||
|
||||
1240
.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
Normal file
1240
.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -718,33 +718,41 @@ toolbar:
|
||||
| A11 | The recommended interface, YAML key and route names | Contract names table | Naming only; cheap before release, costly after v0.1.3 |
|
||||
| A12 | Row states limited to `deleted`, `negative`, `disabled` | Row state | A later plugin needs more; adding a state is additive |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All six questions were answered by the user at the plan-count checkpoint on 2026-10-04. The answers are locked decisions in `12.1-CONTEXT.md`; each question below names the decision that settles it.
|
||||
|
||||
1. **Are the extra framework seams (G1 to G7) accepted into v0.1.3?**
|
||||
- What we know: D-07, D-19 and D-22 cannot be met with the five decided features alone; evidence is quoted above.
|
||||
- What's unclear: whether the user wants all of them in this phase or prefers to relax a decision (for example an organisation field that stays read-only on the user form and is managed only from the organisation's members tab).
|
||||
- Recommendation: present G1 to G7 at the plan-count checkpoint as one table with "needed for D-xx" and let the user accept or cut each.
|
||||
- RESOLVED by D-27: all seven seams (G1 to G7) are accepted into v0.1.3.
|
||||
|
||||
2. **How should the admin form's validation rules be supplied (G5)?**
|
||||
- What we know: `User.Rules()` is the register contract and cannot change.
|
||||
- What's unclear: framework rules interface on the controller versus an admin-only wrapper record type in the plugin.
|
||||
- Recommendation: the controller interface. It is a few lines in `mergedRules`, is useful to every plugin whose API and admin rules differ, and avoids the embedded-struct risks in A7.
|
||||
- RESOLVED by D-28: an optional controller interface returns the rule set per operation; no wrapper record type.
|
||||
|
||||
3. **Does the User Groups screen get a delete button?**
|
||||
- What we know: PHP's group form and list have no delete; D-06 names "deleting a privileged group".
|
||||
- Recommendation: keep the standard form delete (any cabana form has one), guard it per D-06, and clean the pivot rows.
|
||||
- RESOLVED by D-29: User Groups keep the standard form delete, guarded per D-06, with pivot cleanup.
|
||||
|
||||
4. **`last_seen` write policy.**
|
||||
- What we know: PHP only touches it from the CMS session component, at most once per five minutes; the JWT API never does. Go refresh does not load the user.
|
||||
- Recommendation: write on login and on refresh, as one `UPDATE users SET last_seen = now() WHERE id = ? AND (last_seen IS NULL OR last_seen < now() - interval '5 minutes')`, errors logged and ignored so auth never fails on it.
|
||||
- RESOLVED by D-29: written on login and on refresh, at most once per five minutes; a failed write never fails auth.
|
||||
|
||||
5. **Admin-created users: activated or not?**
|
||||
- What we know: in PHP a backend-created user is not activated; with `send_invite` the mail carries a link, without it the admin activates manually from the preview hint.
|
||||
- Recommendation: same in Go; `is_activated` stays a protected key and only the `activate` actions set it.
|
||||
- RESOLVED by D-29: a user created in the admin starts not activated; only the `activate` actions set `is_activated`.
|
||||
|
||||
6. **Does bulk `activate` on an already activated user fail the whole batch?**
|
||||
- What we know: PHP `attemptActivation` throws "User is already active!" mid-loop, leaving earlier rows changed.
|
||||
- Recommendation: skip already-active users and report the affected count; record it as a deliberate deviation (the admin API has no PHP contract to match).
|
||||
- RESOLVED by D-29: bulk `activate` skips users that are already active and reports the affected count.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ Task IDs are assigned by the planner; rows are keyed by unit until then.
|
||||
| TBD | TBD | TBD | SC-2 | — | Groups field sync; organisation members set and clear `organisation_id` | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdminUserGroupsField\|TestAdminOrganisationMembers' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-3 / D-13 / D-14 | — | activate, unban, unsuspend, deactivate, restore, ban, force delete with cleanup | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdminUserActions\|TestAdminUserForceDelete' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | SC-4 | T-12-18 | Without the extra permission a privileged membership change is 403 and nothing changes; privileged code create, rename and delete refused; every other path leaves `users_groups` unchanged | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdminPrivilegedGroups' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | D-30 | T-12.1-38, T-12.1-39 | Without the extra permission, changing the email or password of a privileged-group member, or permanently deleting them (form delete, bulk delete as a whole), is 403 and nothing changes; with it each succeeds; a name-only update stays allowed | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdminPrivilegedMember' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | D-15 | — | Resolver equals PHP `getMergedPermissions` on a table of cases | unit | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestMergedPermissions\|TestPermissionSetScan' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | D-17 | — | `last_seen` written by the auth path; absent from every user payload | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestLastSeen' -count=1` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | D-19 | — | Password mismatch 422; `send_invite` sends one mail; password never in a response | integration | `go -C ../fonoteka.go test ./plugins/golem15/user/... -run 'TestAdminUserPassword\|TestAdminUserInvite' -count=1` | ❌ W0 | ⬜ pending |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: the phase extends SummerCMS's own admin API, plugin contract and admin SPA and ports three admin screens of the user plugin; no third-party service, SDK or remote API is integrated.
|
||||
Reference in New Issue
Block a user