docs(19.6.1-02): complete SPA trigger and record permission locks plan

This commit is contained in:
Jakub Zych
2026-10-22 11:45:07 +02:00
parent 7091d09699
commit 47db9666dd

View File

@@ -0,0 +1,284 @@
---
phase: 19.6.1-backend-administrators-screen
plan: 02
subsystem: admin
tags: [cabana, admin-spa, permissions, fields-yaml, openapi, boardwalk]
requires:
- phase: 19.6.1-01
provides: Administrators and Roles controllers, permission editor providers, Registry.permissionOrder, rolePermissions
provides:
- cabana.PermissionRecordLock and the optional cabana.PermissionRecordLockProvider
- RecordMeta.PermissionLocks (meta.permissionLocks) on show, create and update responses, notes localized
- record-lock save rule in applyPermissionValues (absent keeps stored, differing present value is 403)
- fields.yaml `trigger` (cabana.FieldTrigger on FormField.Trigger) with boot checks
- SPA trigger evaluation (trigger.ts), trigger note in FormField, record-locked rows in PermissionEditorField
- regenerated admin/openapi/admin.json, admin/src/api/schema.d.ts and modules/boardwalk/dist
affects: [19.6.1-03 (SPA and Go unit tests for triggers and record locks), any plugin with a permission editor or a fields.yaml that wants trigger]
actuals:
tokens: 28900
tasks: 2
commits: 2
plan_head_before: e2525d4956e58f693b841324f2837e23fe213d1f
plan_head_after: 7091d09699c30b000a4ae6776ada33560e679252
tech-stack:
added: []
patterns:
- "Per-record display locks travel in the record response meta, never in the form schema"
- "Display rules (trigger, record locks) are applied in the SPA by leaving values out of the save body; the server keeps stored values and stays authoritative"
- "Injection keys (FORM_PERMISSION_LOCKS, FORM_TRIGGER_NOTES) instead of prop threading through FormGrid/FormField/FieldRenderer"
key-files:
created:
- modules/cabana/field_trigger.go
- modules/cabana/field_trigger_smoke_test.go
- modules/cabana/record_locks_smoke_test.go
- modules/cabana/example_permission_record_locks_test.go
- admin/src/components/form/trigger.ts
modified:
- modules/cabana/field_permission.go
- modules/cabana/relation_field.go
- modules/cabana/crud.go
- modules/cabana/relation_child.go
- modules/cabana/backend_roles_admin.go
- modules/cabana/backend_users_admin.go
- modules/cabana/form_schema.go
- modules/cabana/schema_types.go
- modules/cabana/field_dependent.go
- modules/cabana/field_repeater.go
- modules/cabana/admin_openapi.go
- modules/cabana/backendscreens/models/user/fields.yaml
- modules/cabana/backend_screens_smoke_test.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/api/types.ts
- admin/src/components/form/formContext.ts
- admin/src/components/form/control.ts
- admin/src/components/form/formState.ts
- admin/src/components/form/FormField.vue
- admin/src/components/form/fields/PermissionEditorField.vue
- admin/src/components/relation/RelationChildModal.vue
- admin/src/views/FormView.vue
- admin/src/views/PreviewView.vue
- modules/boardwalk/dist
- docs/backend/forms.md
- docs/backend/users-and-permissions.md
key-decisions:
- "Record-lock notes are filled as phrase keys in projectFullRecord and translated where the show and save paths (and the relation child paths) finish, with s.tr"
- "A record-locked row sets data-locked and data-locked-by=\"record\" only where the lock is shown (editable screens); read-only screens show the lock's value only"
- "The trigger note is looked up by the FormGrid scope path, so a repeater row field never picks up a top-level note; RelationChildModal provides an empty note map"
- "The non-superuser schema check in TestAdministratorsFormSmoke now looks for an is_superuser field rather than the string: the permissions field's trigger names is_superuser by design (UI-SPEC C6)"
patterns-established:
- "Optional controller capabilities next to PermissionEditorProvider are asked only for stored records and only through cabana's save/show paths"
- "Triggers are compiled per controller form; settingsFormOnly/controllerFormOnly and the repeater row check refuse them elsewhere"
requirements-completed: [SC-1, SC-2, D-06, D-08, D-16, D-18, D-21, D-22]
coverage:
- id: D1
description: "Record-lock save rule: absent locked code keeps stored, differing present value is 403, equal body passes, request-Locked plus record-locked absent code is not a 403, no locks asked on create; meta.permissionLocks present only with a provider"
requirement: "D-21"
verification:
- kind: unit
ref: "modules/cabana/record_locks_smoke_test.go#TestPermissionRecordLocksSmoke"
status: pass
human_judgment: false
- id: D2
description: "Roles and Administrators saves keep working under the record-lock rule (system role grants, own record)"
requirement: "D-16"
verification:
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestRolesSmoke"
status: pass
- kind: integration
ref: "modules/cabana/backend_screens_smoke_test.go#TestAdministratorsFormSmoke"
status: pass
human_judgment: false
- id: D3
description: "Trigger compile: valid trigger reaches the localized schema; unknown action, unknown condition, extra key, missing key, not a mapping, self reference, unknown controlling field, text controlling field, repeater row are boot errors naming controller, field and trigger; settings and relation forms refuse it; Administrators permissions carries the D-06 trigger"
requirement: "D-06"
verification:
- kind: unit
ref: "modules/cabana/field_trigger_smoke_test.go#TestFieldTriggerSmoke"
status: pass
human_judgment: false
- id: D4
description: "Generated OpenAPI, schema.d.ts and the committed admin build match fresh generations"
requirement: "D-18"
verification:
- kind: integration
ref: "scripts/check-admin-openapi.sh --check"
status: pass
- kind: integration
ref: "scripts/check-admin-dist.sh"
status: pass
human_judgment: false
- id: D5
description: "SPA rendering: trigger note, read-only overrides while Superuser is on, record-locked rows with notes, tab removal for hidden fields, live-region announcement"
requirement: "D-21"
verification: []
human_judgment: true
rationale: "Plan 03 owns the SPA unit tests; the browser behaviour is the Task 2 human-check (verify-work UAT). A throwaway vitest run during execution confirmed the locked row, the payload omission and the trigger note markup, and was not committed."
duration: 23min
completed: 2026-10-22
status: complete
---
# Phase 19.6.1 Plan 02: SPA support for triggers and per-record permission locks Summary
**The admin now shows permissions a stored record fixes (a role's plugin-declared grants, an administrator's own overrides) as locked rows with their notes, through a new optional `cabana.PermissionRecordLockProvider` and `meta.permissionLocks`. fields.yaml gains WinterCMS's `trigger`, which the Administrators form uses to make the overrides read-only while Superuser is on. The OpenAPI types and the embedded admin build were regenerated.**
## Performance
- **Duration:** ~23 min
- **Started:** 2026-10-22T09:21:00Z
- **Completed:** 2026-10-22T09:44:04Z
- **Tasks:** 2
- **Files modified:** 38 (1089 insertions, 95 deletions, dist assets included)
## Accomplishments
- **Record locks (D-21 for D-16 and D-08).**
- `PermissionRecordLock{Code, Value, Note}` and the optional `PermissionRecordLockProvider` sit next to `PermissionEditorProvider`.
- `projectFullRecord` fills `RecordMeta.PermissionLocks`. Show, create and update responses (and the relation child paths) translate the notes in the request locale. The key is omitted when no lock applies.
- `applyPermissionValues` on update: a record-locked code that is absent from the body keeps its stored value. A present value must equal the stored one, or the save gets a 403 on the field. The code is skipped in the request-Locked comparison, so a system role whose code grant is not in its stored JSON saves without a 403.
- Roles lock every `rolePermissions(code)` grant, in catalog order, at value 1 with `locked_by_code`. Administrators lock every offered code on the editor's own record at its stored override, with `locked_self`.
- **Field trigger (D-21 for D-06).**
- `FieldTrigger{Action, Field, Condition}` on `FormField.Trigger`.
- `compileTrigger` takes exactly action, field and condition. `checkTriggers` runs in `compileForm` through `bootErr`.
- Repeater rows, settings forms and relation forms refuse the key.
- The Administrators `permissions` field declares `trigger: {action: disable, field: is_superuser, condition: checked}`.
- **SPA.**
- `trigger.ts` (`triggerState`, `applyTriggers`); injection keys `FORM_PERMISSION_LOCKS` and `FORM_TRIGGER_NOTES`.
- `permissionValues(field, value, locked?)` and `editablePayload(fields, values, mode, locks?)`.
- FormView: every use of the field list goes through `applyTriggers`, applied with the whole form's values. That covers tabs, panel fields, the banner's field names, the dirty snapshot and the save body. When the active tab disappears, the first remaining tab becomes active. A flip to disabled is announced as "<label>: <note>". Values stay in the form while a field is disabled or hidden.
- FormField shows the trigger note (Info 14px, `data-trigger-note`, joined to `aria-describedby`) between the label and the control.
- PermissionEditorField shows record-locked rows: the lock's value, disabled with `aria-disabled`, the Lock icon, the lock's note, `data-locked` and `data-locked-by="record"`. A record lock wins over a request lock, and read-only screens show the value only.
- PreviewView applies `hide` only.
- **Generated and built.** `admin/openapi/admin.json` and `schema.d.ts` are committed with each type change. `modules/boardwalk/dist` was rebuilt and committed in Task 2.
- **Docs.**
- `docs/backend/forms.md`: new sections "Permissions the record fixes" and "Triggers" (the "Field options" paragraph no longer lists `trigger` as refused).
- `docs/backend/users-and-permissions.md`: the read-only overrides, the own-record locks and the locked role grants.
- cabana README: Features and API rows.
## Task Commits
1. **Task 1 (tracer): per-record locked permission values.** `705f94d` (feat)
2. **Task 2: field trigger and admin build.** `7091d09` (feat)
Tracer feedback gate: the Task 1 `<verify>` commands, all automated, were re-run end to end (short suite, smoke, hello, OpenAPI check, typecheck, vitest, docs checker, Plan 01 Postgres smokes 2/2). All passed, so Task 2 went ahead.
## Files Created/Modified
- `modules/cabana/field_permission.go`: the lock types, `permissionRecordLocks`, `localizePermissionLocks`, and the save rule in `applyPermissionValues`.
- `modules/cabana/relation_field.go`: `RecordMeta.PermissionLocks`.
- `modules/cabana/crud.go` and `relation_child.go`: fill the locks in `projectFullRecord` and translate them in the show and save paths.
- `modules/cabana/backend_roles_admin.go` and `backend_users_admin.go`: `AdminPermissionRecordLocks`.
- `modules/cabana/field_trigger.go`, `form_schema.go`, `schema_types.go`, `field_dependent.go` and `field_repeater.go`: trigger compile, checks and refusals.
- `modules/cabana/backendscreens/models/user/fields.yaml`: the D-06 trigger.
- `modules/cabana/admin_openapi.go`: the record and form schema descriptions.
- `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`: `permissioneditor.locked_by_code`, `permissioneditor.locked_self`, `form.trigger_disabled_on` and `form.trigger_disabled_off`, verbatim from UI-SPEC.
- SPA files as listed in the frontmatter.
## Decisions Made
- I followed the plan's decisions: the exported optional interface, the payload shape, the save rule, injection keys instead of prop threading, `checked` and `unchecked` only, and the dist rebuilt once, in Task 2.
- Notes are translated after the transaction, where the response is finished. `projectFullRecord` has no translator.
- In the editor, `data-locked-by="record"` appears only where the lock is shown. Read-only screens follow the existing `showsLock` rule.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] The Plan 01 schema assertion matched the trigger's reference to is_superuser**
- **Found during:** Task 2 (Docker-backed `go test ./modules/cabana`)
- **Issue:** `TestAdministratorsFormSmoke` checked that a limited editor's form schema does not contain the string `is_superuser`. The permissions field's trigger now names `is_superuser` by design: UI-SPEC C6 says a controlling field missing from the schema counts as off.
- **Fix:** The assertion now checks that no schema field is named `is_superuser`. The D-03 intent is unchanged: the switch itself is still removed for non-superusers.
- **Files modified:** modules/cabana/backend_screens_smoke_test.go
- **Commit:** 7091d09
**2. [Rule 3 - Blocking] The docs checker needs a `src=` source for Go blocks**
- **Issue:** The docs example of a record-lock provider needs a `src=` reference to compiled, tested code.
- **Fix:** Added `modules/cabana/example_permission_record_locks_test.go` (`AccessRolesController.AdminPermissionRecordLocks` with `Example_permissionRecordLocks`) and referenced it from `docs/backend/forms.md`.
- **Commit:** 705f94d
**3. [Rule 2 - Missing critical] Translation and isolation outside the planned paths**
- The relation child show and save paths also translate `meta.permissionLocks`, so no response can carry an untranslated phrase key.
- `RelationChildModal.vue` provides an empty `FORM_TRIGGER_NOTES`, so a child field that shares a top-level field's name never shows that field's note.
- `FormField` looks notes up by the FormGrid scope path, so repeater row fields never match.
- **Commits:** 705f94d, 7091d09
**4. Docs beyond the listed files.** `docs/backend/users-and-permissions.md` gained three sentences (read-only overrides, own-record locks, locked role grants) so that page matches the behaviour. The "Field options" paragraph in forms.md had listed `trigger` among the refused WinterCMS keys; I corrected it.
**5. Commit scope.** Commits use `feat(19.6.1-02): ...` as the orchestrator instructed, instead of the plan's `feat(cabana,admin): ...`. No co-author tags.
---
**Total deviations:** 3 auto-fixed (1 bug, 1 blocking, 1 missing-critical), plus 2 notes.
**Impact on plan:** None on scope.
## Existing tests
One existing test changed: the `is_superuser` schema assertion in `TestAdministratorsFormSmoke` (deviation 1). Every other existing Go and vitest test passes unchanged.
## Verification
- `go vet ./...`: pass.
- `go test -count=1 -short ./...`: pass.
- `go -C examples/hello test ./...`: pass.
- `TestPermissionRecordLocksSmoke`, `TestFieldTriggerSmoke` and `TestBackendScreensSmoke`: 3/3 `--- PASS`.
- `go test -count=1 ./modules/cabana ./modules/surf ./modules/boardwalk` (Docker reachable directly): pass. This includes the Plan 01 Postgres smokes, 4/4.
- `scripts/check-admin-openapi.sh --check`: pass after each commit.
- `npm --prefix admin run typecheck`: pass.
- `npm --prefix admin run test`: 93 files, 1435 tests, all pass. One earlier run hit a 5s timeout in `tests/smoke/list.smoke.test.ts` under load. It passed alone and on the full rerun; the test is unrelated to forms.
- `scripts/check-admin-dist.sh`: "modules/boardwalk/dist matches a fresh build".
- `go test -run '^TestDocsTree$' ./cmd/summer` and `summer docs:build --check`: pass.
- `admin/package.json` and `package-lock.json` are unchanged. `npm ls` reports the installed tree consistent with the lockfile. I did not run `npm ci`, because node_modules was present and other sessions share it.
- All acceptance greps for both tasks print the expected counts.
## Issues Encountered
None blocking.
## Known Stubs
None.
## Threat Flags
None. The new surface (`meta.permissionLocks`, the `trigger` key) is covered by T-19.6.1-18 to T-19.6.1-23:
- The server never trusts a trigger or a lock from the client.
- Notes render through text interpolation only; the touched components contain no `v-html`.
- No package was added.
## User Setup Required
None.
## Next Phase Readiness
- Plan 03 can unit-test:
- `triggerState` and `applyTriggers` (trigger.ts);
- the record-locked rows in `PermissionEditorField` (inject `FORM_PERMISSION_LOCKS`);
- `editablePayload` with locks;
- the FormField note (`FORM_TRIGGER_NOTES`, id `<controlId>-trigger`);
- the FormView tab fallback and announcement.
- On the server side it can unit-test `compileTrigger` and `checkTriggers`, plus own-record and system-role locks against Postgres through `screensEnv`.
- Manual UAT (Task 2 human-check):
- As a superuser, open another superuser: the overrides are read-only with the note, and switching Superuser off makes them editable.
- Your own record shows locked rows.
- A role that plugins grant to shows those grants locked with "Granted to this role by a plugin."
---
*Phase: 19.6.1-backend-administrators-screen*
*Completed: 2026-10-22*
## Self-Check: PASSED