feat(12.1-02): permissioneditor field in radio or checkbox mode
- type: permissioneditor with mode radio (1, -1) or checkbox (1); the controller serves the options per request through cabana.PermissionEditorProvider and reads and stores the values - a save answers 422 for a non-object, an unknown code or a value outside the mode's set and 403 for a changed locked code; stored codes that are not offered are kept - record responses carry the stored permissions as an object - SPA: PermissionEditorField with sections by tab, locked rows and a read-only mode for the preview - README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-CXbMmgdh.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-CXbMmgdh.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-CvMS0tdW.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-CvMS0tdW.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-DEO3TzjB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B2epb8_Z.css">
|
||||
<script type="module" crossorigin src="./assets/index-CvMS0tdW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CXbMmgdh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -22,6 +22,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
|
||||
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
||||
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
|
||||
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
@@ -209,6 +210,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
|
||||
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
|
||||
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
|
||||
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
||||
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
|
||||
|
||||
@@ -262,7 +262,7 @@ func AdminListSchema() {}
|
||||
// AdminFormSchema documents the form schema route.
|
||||
//
|
||||
// @Summary Admin form schema
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.
|
||||
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
|
||||
@@ -105,6 +105,9 @@ type CompiledController struct {
|
||||
// virtual is the set of form field names the controller lists through
|
||||
// pact.FormVirtualFields: never bound, filled or projected.
|
||||
virtual map[string]bool
|
||||
// permissions are the form's `type: permissioneditor` fields: field name
|
||||
// to mode (radio or checkbox).
|
||||
permissions map[string]string
|
||||
}
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
|
||||
@@ -544,13 +544,17 @@ type actionConflict struct{}
|
||||
func (actionConflict) Error() string { return "cabana: action does not apply" }
|
||||
|
||||
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
|
||||
// values keyed by field name, and their labels.
|
||||
// values keyed by field name with their labels, and the stored permissions of
|
||||
// every permissioneditor field as an object (D-16).
|
||||
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
|
||||
data := projectRecord(cc, model)
|
||||
meta, err := projectRelationFields(ctx, tx, cc, model, data)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
if err := projectPermissionFields(withTx(ctx, tx), cc, model, data); err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
return RecordResult{Data: data, Meta: meta}, nil
|
||||
}
|
||||
|
||||
@@ -580,6 +584,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
permissions, err := liftPermissionValues(cc, in.Body, op)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
var result RecordResult
|
||||
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withVirtualFields(withTx(ctx, tx), virtual)
|
||||
@@ -633,6 +641,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// D-16: permission values are checked against the controller's
|
||||
// options and stored by the controller before the row write.
|
||||
if err := applyPermissionValues(ctx, cc, target, permissions, update); err != nil {
|
||||
return err
|
||||
}
|
||||
// D-18: submitted ids pass the same scoped query as the options
|
||||
// endpoint; belongsTo keys land before the row write, pivot rows after.
|
||||
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestDatepickerSmokeCompile(t *testing.T) {
|
||||
"minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"},
|
||||
"min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"},
|
||||
"key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
|
||||
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"},
|
||||
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := activateFields(t, datepickerFields(tc.field))
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestDatepickerCompile(t *testing.T) {
|
||||
"ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"},
|
||||
"ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"},
|
||||
"key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
|
||||
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload or datepicker"},
|
||||
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
|
||||
"date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"},
|
||||
"datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"},
|
||||
"time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"},
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
@@ -17,6 +19,8 @@ type Member struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
// Permissions is a JSON object of permission code to value.
|
||||
Permissions string `gorm:"column:permissions"`
|
||||
}
|
||||
|
||||
func (Member) TableName() string { return "acme_roster_members" }
|
||||
@@ -40,6 +44,8 @@ var (
|
||||
_ pact.FormRules = MembersController{}
|
||||
_ pact.FormBeforeCreate = MembersController{}
|
||||
_ pact.FormBeforeUpdate = MembersController{}
|
||||
|
||||
_ cabana.PermissionEditorProvider = MembersController{}
|
||||
)
|
||||
|
||||
func (MembersController) ID() string { return "acme.roster.members" }
|
||||
@@ -88,6 +94,40 @@ func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminPermissionOptions lists the permissions the `type: permissioneditor`
|
||||
// field offers, in display order. The principal on ctx decides what is locked.
|
||||
func (MembersController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
mayExport := cabana.Allows(principal, []string{"acme.roster.manage"})
|
||||
return []cabana.PermissionOption{
|
||||
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content"},
|
||||
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
|
||||
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports", Locked: !mayExport},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues reads the permissions stored on the record.
|
||||
func (MembersController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
||||
values := map[string]int{}
|
||||
if raw := record.(*Member).Permissions; raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues stores the checked set on the model. The save
|
||||
// writes the row afterwards, in the same transaction.
|
||||
func (MembersController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.(*Member).Permissions = string(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword stands in for the application's password hasher.
|
||||
func hashPassword(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
@@ -107,9 +147,23 @@ func Example_formSeams() {
|
||||
_, inSave := cabana.VirtualFieldsFromContext(ctx)
|
||||
err := ctl.FormBeforeCreate(ctx, member)
|
||||
fmt.Println(inSave, err, member.Password == "")
|
||||
|
||||
// The permission editor: three options, the last one locked for an
|
||||
// administrator without acme.roster.manage (here: nobody is signed in).
|
||||
options, _ := ctl.AdminPermissionOptions(ctx, "permissions")
|
||||
for _, option := range options {
|
||||
fmt.Println(option.Code, option.Locked)
|
||||
}
|
||||
_ = ctl.AdminSetPermissionValues(ctx, "permissions", member, map[string]int{"posts.edit": 1, "posts.publish": -1})
|
||||
values, _ := ctl.AdminPermissionValues(ctx, "permissions", member)
|
||||
fmt.Println(member.Permissions, len(values))
|
||||
// Output:
|
||||
// [password password_confirmation notify]
|
||||
// create: required|between:8,255|confirmed
|
||||
// update: nullable|between:8,255|confirmed
|
||||
// false <nil> true
|
||||
// posts.edit false
|
||||
// posts.publish false
|
||||
// reports.export true
|
||||
// {"posts.edit":1,"posts.publish":-1} 2
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormFi
|
||||
return fmt.Errorf("%s is only valid on type: fileupload", key)
|
||||
}
|
||||
}
|
||||
if _, ok := values["mode"]; ok && typ != "datepicker" {
|
||||
return fmt.Errorf("mode is only valid on type: fileupload or datepicker")
|
||||
if _, ok := values["mode"]; ok && typ != "datepicker" && typ != permissionFieldType {
|
||||
return fmt.Errorf("mode is only valid on type: fileupload, datepicker or permissioneditor")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
340
modules/cabana/field_permission.go
Normal file
340
modules/cabana/field_permission.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
|
||||
// permissionFieldType is the fields.yaml type of the permission editor (D-16).
|
||||
const permissionFieldType = "permissioneditor"
|
||||
|
||||
// permissionRefusedKeys are generic field keys with no meaning on a
|
||||
// permission editor: its choices come from the controller per request.
|
||||
var permissionRefusedKeys = []string{"options", "default", "nameFrom", "emptyOption", "relation", "preset"}
|
||||
|
||||
// permissionLockedKey is the message a changed locked permission is refused
|
||||
// with, on the field.
|
||||
const permissionLockedKey = "backend::lang.permissioneditor.locked"
|
||||
|
||||
// PermissionOption is one permission a `type: permissioneditor` field offers.
|
||||
// Code is the permission code stored with the record. Label, Tab and Comment
|
||||
// are phrase keys or text, localized per request; options with the same Tab
|
||||
// are shown as one section, and an option without a Tab goes to a last
|
||||
// section. Locked marks a permission the requesting administrator may see but
|
||||
// not change: the admin disables its control and a save that changes its
|
||||
// value is answered 403.
|
||||
type PermissionOption struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Tab string `json:"tab,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locked bool `json:"locked,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionEditorProvider is implemented by an admin controller whose form
|
||||
// has a `type: permissioneditor` field. The framework validates what an
|
||||
// administrator submits against the offered options; reading and writing the
|
||||
// record's stored permissions stays with the controller, so the storage shape
|
||||
// is the plugin's decision.
|
||||
//
|
||||
// AdminPermissionOptions returns the permissions field offers the
|
||||
// administrator on ctx (read with bouncer.User), in display order. It is
|
||||
// called for the form schema and again inside every save.
|
||||
//
|
||||
// AdminPermissionValues returns the permissions stored on record for field as
|
||||
// code to value. It is called to show a record and, inside a save, before the
|
||||
// change is applied.
|
||||
//
|
||||
// AdminSetPermissionValues stores values on record for field. It runs inside
|
||||
// the save's transaction (cabana.TxFromContext), before the record's row is
|
||||
// written, so setting the model's column is enough. values holds the
|
||||
// submitted codes with their checked values plus every stored code that is
|
||||
// not offered, unchanged; an offered code that is absent has no value
|
||||
// (inherit, or not allowed). An error fails the save like a Form hook's.
|
||||
type PermissionEditorProvider interface {
|
||||
AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error)
|
||||
AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error)
|
||||
AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error
|
||||
}
|
||||
|
||||
// compilePermissionKeys checks the keys of a `type: permissioneditor` field:
|
||||
// mode is required and must be radio (allow, inherit, deny) or checkbox
|
||||
// (allow), and the keys that describe choices or a column value are refused.
|
||||
// On every other type mode is checked by compileFileuploadKeys.
|
||||
func compilePermissionKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
if typ != permissionFieldType {
|
||||
return nil
|
||||
}
|
||||
for _, key := range permissionRefusedKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is not valid on type: permissioneditor", key)
|
||||
}
|
||||
}
|
||||
mode, err := nodeString(values["mode"])
|
||||
if err != nil || (mode != "radio" && mode != "checkbox") {
|
||||
return fmt.Errorf("mode must be radio or checkbox on type: permissioneditor")
|
||||
}
|
||||
field.Mode = mode
|
||||
return nil
|
||||
}
|
||||
|
||||
// compilePermissionFields stops boot when a form has a permissioneditor field
|
||||
// and its controller does not implement PermissionEditorProvider, and records
|
||||
// the fields' modes for the save path.
|
||||
func compilePermissionFields(pluginID string, cc *CompiledController) error {
|
||||
if cc == nil || cc.Form == nil {
|
||||
return nil
|
||||
}
|
||||
for _, field := range cc.Form.Fields {
|
||||
if field.Type != permissionFieldType {
|
||||
continue
|
||||
}
|
||||
if provider, ok := cc.Controller.(PermissionEditorProvider); !ok || provider == nil {
|
||||
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath,
|
||||
fmt.Errorf("field %s: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider", field.Name))
|
||||
}
|
||||
if cc.permissions == nil {
|
||||
cc.permissions = map[string]string{}
|
||||
}
|
||||
cc.permissions[field.Name] = field.Mode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// permissionFieldNames are the controller's permissioneditor fields in a
|
||||
// stable order.
|
||||
func permissionFieldNames(cc *CompiledController) []string {
|
||||
if cc == nil || len(cc.permissions) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(cc.permissions))
|
||||
for name := range cc.permissions {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// localizePermissionOptions fills the permission options of every
|
||||
// permissioneditor field of a localized form view for one request. The
|
||||
// options are asked from the controller with the request context, so they may
|
||||
// depend on the administrator. fields is the view's own slice; the cached
|
||||
// schema is not touched.
|
||||
func localizePermissionOptions(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, fields []FormField) error {
|
||||
if cc == nil || len(cc.permissions) == 0 {
|
||||
return nil
|
||||
}
|
||||
provider, ok := cc.Controller.(PermissionEditorProvider)
|
||||
if !ok || provider == nil {
|
||||
return fmt.Errorf("cabana: controller %s: no PermissionEditorProvider", controllerID(cc))
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
local := towel.WithLocale(ctx, schemaLocale(ctx, tr))
|
||||
for i := range fields {
|
||||
if fields[i].Type != permissionFieldType {
|
||||
continue
|
||||
}
|
||||
options, err := provider.AdminPermissionOptions(ctx, fields[i].Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]PermissionOption, len(options))
|
||||
for j, option := range options {
|
||||
out[j] = PermissionOption{
|
||||
Code: option.Code,
|
||||
Label: translateKey(local, tr, option.Label),
|
||||
Tab: translateKey(local, tr, option.Tab),
|
||||
Comment: translateKey(local, tr, option.Comment),
|
||||
Locked: option.Locked,
|
||||
}
|
||||
}
|
||||
fields[i].PermissionOptions = out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// permissionValue is one permissioneditor value lifted from a save body.
|
||||
type permissionValue struct {
|
||||
field string
|
||||
mode string
|
||||
values map[string]int
|
||||
}
|
||||
|
||||
// liftPermissionValues takes the permissioneditor values out of a save body
|
||||
// before scalar projection, which drops every nested value. Only fields
|
||||
// present in the body whose context allows op are lifted. A value must be a
|
||||
// JSON object of permission code to integer; anything else is
|
||||
// validation_failed on the field.
|
||||
func liftPermissionValues(cc *CompiledController, body map[string]any, op string) ([]permissionValue, error) {
|
||||
if cc == nil || len(cc.permissions) == 0 || body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
details := map[string]any{}
|
||||
var out []permissionValue
|
||||
for _, name := range permissionFieldNames(cc) {
|
||||
if !contextAllows(cc, name, op) {
|
||||
continue
|
||||
}
|
||||
raw, present := body[name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
object, ok := raw.(map[string]any)
|
||||
values := make(map[string]int, len(object))
|
||||
for code, item := range object {
|
||||
n, isInt := permissionInt(item)
|
||||
if !isInt {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
values[code] = n
|
||||
}
|
||||
if !ok {
|
||||
details[name] = []string{"The " + name + " field must be an object of permission codes."}
|
||||
continue
|
||||
}
|
||||
out = append(out, permissionValue{field: name, mode: cc.permissions[name], values: values})
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return nil, &ValidationError{Details: details}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// permissionInt accepts a JSON integer only: no strings, booleans, fractions
|
||||
// or nested values.
|
||||
func permissionInt(value any) (int, bool) {
|
||||
switch n := value.(type) {
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil || i < math.MinInt32 || i > math.MaxInt32 {
|
||||
return 0, false
|
||||
}
|
||||
return int(i), true
|
||||
case float64:
|
||||
if n != math.Trunc(n) || n < math.MinInt32 || n > math.MaxInt32 {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
if n < math.MinInt32 || n > math.MaxInt32 {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// permissionAllowed reports whether value is in mode's set: 1 or -1 for
|
||||
// radio, 1 for checkbox.
|
||||
func permissionAllowed(mode string, value int) bool {
|
||||
if mode == "radio" {
|
||||
return value == 1 || value == -1
|
||||
}
|
||||
return value == 1
|
||||
}
|
||||
|
||||
// applyPermissionValues checks the lifted permission values against the
|
||||
// controller's options and hands the next set to the controller, inside the
|
||||
// save's transaction and before the row write (D-16; T-12.1-13). Every
|
||||
// submitted code must be offered and every value in the mode's set (a 0 means
|
||||
// "no value" and is dropped): otherwise 422 on the field. A locked option's
|
||||
// stored and submitted value must be equal: otherwise a ForbiddenError (403)
|
||||
// naming the field. The next set is the stored codes that are not offered,
|
||||
// unchanged, plus the submitted codes.
|
||||
func applyPermissionValues(ctx context.Context, cc *CompiledController, record any, lifted []permissionValue, update bool) error {
|
||||
if len(lifted) == 0 {
|
||||
return nil
|
||||
}
|
||||
provider, ok := cc.Controller.(PermissionEditorProvider)
|
||||
if !ok || provider == nil {
|
||||
return &CapabilityError{ControllerID: controllerID(cc)}
|
||||
}
|
||||
for _, value := range lifted {
|
||||
options, err := provider.AdminPermissionOptions(ctx, value.field)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
offered := make(map[string]PermissionOption, len(options))
|
||||
for _, option := range options {
|
||||
offered[option.Code] = option
|
||||
}
|
||||
submitted := make(map[string]int, len(value.values))
|
||||
for code, n := range value.values {
|
||||
if _, known := offered[code]; !known {
|
||||
return &ValidationError{Details: map[string]any{value.field: []string{"The " + value.field + " field contains an unknown permission."}}}
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if !permissionAllowed(value.mode, n) {
|
||||
return &ValidationError{Details: map[string]any{value.field: []string{"The " + value.field + " field contains an invalid value."}}}
|
||||
}
|
||||
submitted[code] = n
|
||||
}
|
||||
stored := map[string]int{}
|
||||
if update {
|
||||
current, err := provider.AdminPermissionValues(ctx, value.field, record)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
for code, n := range current {
|
||||
stored[code] = n
|
||||
}
|
||||
}
|
||||
next := make(map[string]int, len(stored)+len(submitted))
|
||||
for code, n := range stored {
|
||||
if _, known := offered[code]; !known {
|
||||
next[code] = n
|
||||
}
|
||||
}
|
||||
for _, option := range options {
|
||||
if option.Locked && stored[option.Code] != submitted[option.Code] {
|
||||
return &ForbiddenError{Details: map[string]any{value.field: []string{permissionLockedKey}}}
|
||||
}
|
||||
}
|
||||
for code, n := range submitted {
|
||||
next[code] = n
|
||||
}
|
||||
if err := provider.AdminSetPermissionValues(ctx, value.field, record, next); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// projectPermissionFields sets data[field] to the record's stored permissions
|
||||
// for every permissioneditor field, as an object that is never null.
|
||||
func projectPermissionFields(ctx context.Context, cc *CompiledController, record any, data map[string]any) error {
|
||||
if cc == nil || len(cc.permissions) == 0 {
|
||||
return nil
|
||||
}
|
||||
provider, ok := cc.Controller.(PermissionEditorProvider)
|
||||
if !ok || provider == nil {
|
||||
return &CapabilityError{ControllerID: controllerID(cc)}
|
||||
}
|
||||
for _, name := range permissionFieldNames(cc) {
|
||||
values, err := provider.AdminPermissionValues(ctx, name, record)
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
out := make(map[string]int, len(values))
|
||||
for code, n := range values {
|
||||
out[code] = n
|
||||
}
|
||||
data[name] = out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -25,7 +25,7 @@ var (
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||
"password": {},
|
||||
"password": {}, "permissioneditor": {},
|
||||
}
|
||||
formSpans = map[string]struct{}{
|
||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||
@@ -548,6 +548,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
||||
if err := compilePartialPath(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compilePermissionKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compileFileuploadKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -699,6 +700,13 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
kept = append(kept, field)
|
||||
}
|
||||
// A permission editor's options are the controller's answer for this
|
||||
// administrator (D-16); kept is this request's own slice.
|
||||
if err := localizePermissionOptions(r.Context(), s.translator(), cc, kept); err != nil {
|
||||
slog.Error("cabana: permission options failed", "controller", controllerID(cc), "error", err)
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
view.Fields = kept
|
||||
view.Assets = s.controllerAssets(cc)
|
||||
meta := map[string]any{}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
@@ -43,9 +45,12 @@ type rosterPerson struct {
|
||||
JoinedIP *string `gorm:"column:joined_ip"`
|
||||
// Password is a stored hash. The form's password field is virtual: the
|
||||
// controller's hooks derive this column from the submitted value.
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
// Permissions is the permission editor's storage: a JSON object of code
|
||||
// to value, or NULL.
|
||||
Permissions *string `gorm:"column:permissions"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (rosterPerson) TableName() string { return "roster_people" }
|
||||
@@ -258,6 +263,60 @@ func (rosterController) PartialData(_ context.Context, name string, record any)
|
||||
return rosterStatus{}, nil
|
||||
}
|
||||
|
||||
// rosterPermissionCodes are the permissions the people form offers: two tabs
|
||||
// and one permission without a tab. reports.export is locked for an
|
||||
// administrator without acme.roster.manage.
|
||||
var rosterPermissionCodes = []cabana.PermissionOption{
|
||||
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content", Comment: "acme.roster::lang.permissions.posts_edit_comment"},
|
||||
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
|
||||
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports"},
|
||||
{Code: "misc.beta", Label: "acme.roster::lang.permissions.misc_beta"},
|
||||
}
|
||||
|
||||
// AdminPermissionOptions serves the permission editor's options per
|
||||
// administrator.
|
||||
func (rosterController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
if field != "permissions" {
|
||||
return nil, fmt.Errorf("unknown permission field %s", field)
|
||||
}
|
||||
principal, _ := bouncer.User(ctx)
|
||||
out := append([]cabana.PermissionOption(nil), rosterPermissionCodes...)
|
||||
for i := range out {
|
||||
if out[i].Code == "reports.export" {
|
||||
out[i].Locked = !cabana.Allows(principal, []string{"acme.roster.manage"})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues reads the stored JSON object.
|
||||
func (rosterController) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
|
||||
person := record.(*rosterPerson)
|
||||
out := map[string]int{}
|
||||
if person.Permissions == nil || *person.Permissions == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues writes the JSON object onto the model; the save
|
||||
// writes the row.
|
||||
func (rosterController) AdminSetPermissionValues(ctx context.Context, _ string, record any, values map[string]int) error {
|
||||
if _, ok := cabana.TxFromContext(ctx); !ok {
|
||||
return fmt.Errorf("no transaction on the context")
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(raw)
|
||||
record.(*rosterPerson).Permissions = &text
|
||||
return nil
|
||||
}
|
||||
|
||||
// rosterLocked is the sentinel name of a person the roster's actions refuse.
|
||||
const rosterLocked = "Locked"
|
||||
|
||||
|
||||
@@ -386,3 +386,170 @@ func TestPresetSchema(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionEditorSmoke drives `type: permissioneditor` through the
|
||||
// assembled router on PostgreSQL (D-16; T-12.1-13): options per administrator,
|
||||
// the code and value checks, the locked guard, kept unknown codes and the
|
||||
// boot rules.
|
||||
func TestPermissionEditorSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
legacy := `{"legacy.code":1,"reports.export":1}`
|
||||
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &legacy})
|
||||
record := fmt.Sprintf("%s/%d", rosterPeople, id)
|
||||
stored := func(t *testing.T) map[string]int {
|
||||
t.Helper()
|
||||
person := rosterLoad(t, gdb, id)
|
||||
out := map[string]int{}
|
||||
if person.Permissions != nil {
|
||||
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
|
||||
t.Fatalf("stored permissions %q: %v", *person.Permissions, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
same := func(t *testing.T, got, want map[string]int) {
|
||||
t.Helper()
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("permissions = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
const unknown = "The permissions field contains an unknown permission."
|
||||
const invalid = "The permissions field contains an invalid value."
|
||||
const shape = "The permissions field must be an object of permission codes."
|
||||
|
||||
t.Run("the schema carries localized options, locked only for the limited admin", func(t *testing.T) {
|
||||
view, raw := rosterFormSchema(t, env, "bearer")
|
||||
if !strings.Contains(raw, `"name":"permissions","type":"permissioneditor","label":"Permissions","tab":"Permissions","context":"update","mode":"radio","permissionOptions":[{"code":"posts.edit","label":"Edit posts","tab":"Content","comment":"Change the text of any post."},{"code":"posts.publish","label":"Publish posts","tab":"Content"},{"code":"reports.export","label":"Export reports","tab":"Reports"},{"code":"misc.beta","label":"Try beta features"}]`) {
|
||||
t.Fatalf("permission field is not in the schema: %s", raw)
|
||||
}
|
||||
if strings.Contains(raw, `"locked"`) {
|
||||
t.Fatalf("an option is locked for the full admin: %s", raw)
|
||||
}
|
||||
_ = view
|
||||
limited, raw := rosterFormSchema(t, env, "limited")
|
||||
if !strings.Contains(raw, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) || strings.Count(raw, `"locked":true`) != 1 {
|
||||
t.Fatalf("limited admin's options: %s", raw)
|
||||
}
|
||||
// The cached schema was not mutated by either request.
|
||||
for _, field := range limited.Fields {
|
||||
if field.Type == "permissioneditor" && len(field.PermissionOptions) != 4 {
|
||||
t.Fatalf("options = %+v", field.PermissionOptions)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("show returns the stored codes as an object", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"reports.export":1}`) {
|
||||
t.Fatalf("show: %s", rec.Body.String())
|
||||
}
|
||||
blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true})
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, blank), "", "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"permissions":{}`) {
|
||||
t.Fatalf("show without stored permissions: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an update stores offered codes, drops inherit and keeps a stored code that is not offered", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0,"reports.export":1}}`, "bearer")
|
||||
want := map[string]int{"legacy.code": 1, "posts.edit": 1, "posts.publish": -1, "reports.export": 1}
|
||||
same(t, stored(t), want)
|
||||
got, _ := rosterRecord(t, rec.Body.Bytes()).Data["permissions"].(map[string]any)
|
||||
if len(got) != 4 || got["posts.publish"] != float64(-1) || got["legacy.code"] != float64(1) {
|
||||
t.Fatalf("update answered %v", got)
|
||||
}
|
||||
// An offered code that is left out goes back to inherit.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"reports.export":1}}`, "bearer")
|
||||
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
|
||||
// A save without the field leaves the column alone.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Perm B"}`, "bearer")
|
||||
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
|
||||
})
|
||||
|
||||
t.Run("an unknown code, a value outside the set and a non-object are 422", func(t *testing.T) {
|
||||
before := stored(t)
|
||||
for body, message := range map[string]string{
|
||||
`{"permissions":{"posts.edit":1,"admin.root":1}}`: unknown,
|
||||
// A stored code that is not offered cannot be submitted either.
|
||||
`{"permissions":{"legacy.code":1}}`: unknown,
|
||||
`{"permissions":{"posts.edit":2}}`: invalid,
|
||||
`{"permissions":{"posts.edit":-2}}`: invalid,
|
||||
`{"permissions":{"posts.edit":"1"}}`: shape,
|
||||
`{"permissions":{"posts.edit":1.5}}`: shape,
|
||||
`{"permissions":{"posts.edit":true}}`: shape,
|
||||
`{"permissions":{"posts.edit":{"a":1}}}`: shape,
|
||||
`{"permissions":["posts.edit"]}`: shape,
|
||||
`{"permissions":"posts.edit"}`: shape,
|
||||
`{"permissions":null}`: shape,
|
||||
} {
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, body, "bearer")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message)
|
||||
}
|
||||
same(t, stored(t), before)
|
||||
})
|
||||
|
||||
t.Run("a changed locked code is 403 for the limited admin and nothing is written", func(t *testing.T) {
|
||||
before := stored(t)
|
||||
const locked = "You cannot change this permission."
|
||||
// Removing it (leaving it out), denying it and renaming at the same time.
|
||||
for _, body := range []string{
|
||||
`{"name":"Sneaky","permissions":{"posts.edit":1}}`,
|
||||
`{"name":"Sneaky","permissions":{"posts.edit":1,"reports.export":-1}}`,
|
||||
`{"name":"Sneaky","permissions":{"reports.export":0}}`,
|
||||
} {
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPut, record, body, "limited")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
|
||||
}
|
||||
same(t, stored(t), before)
|
||||
if person := rosterLoad(t, gdb, id); person.Name != "Perm B" {
|
||||
t.Fatalf("a refused save renamed the person: %q", person.Name)
|
||||
}
|
||||
// Granting it where it is not stored is refused too.
|
||||
bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, bare), `{"permissions":{"reports.export":1}}`, "limited")
|
||||
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
|
||||
if person := rosterLoad(t, gdb, bare); person.Permissions != nil {
|
||||
t.Fatalf("a refused save wrote %q", *person.Permissions)
|
||||
}
|
||||
|
||||
// The limited admin may change the other codes while the locked one
|
||||
// keeps its stored value.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1,"reports.export":1}}`, "limited")
|
||||
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1, "reports.export": 1})
|
||||
// The full admin may change it.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1}}`, "bearer")
|
||||
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1})
|
||||
})
|
||||
|
||||
t.Run("a field hidden on create is not written by a create", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1","permissions":{"posts.edit":1}}`, "bearer")
|
||||
created := rosterRecord(t, rec.Body.Bytes())
|
||||
newID, _ := created.Data["id"].(float64)
|
||||
if person := rosterLoad(t, gdb, uint(newID)); person.Permissions != nil {
|
||||
t.Fatalf("create wrote permissions %q", *person.Permissions)
|
||||
}
|
||||
if got, ok := created.Data["permissions"].(map[string]any); !ok || len(got) != 0 {
|
||||
t.Fatalf("create answered permissions %v", created.Data["permissions"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("boot rules", func(t *testing.T) {
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
|
||||
"field permissions: mode must be radio or checkbox on type: permissioneditor", rosterFieldsFile)
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
|
||||
"mode must be radio or checkbox on type: permissioneditor")
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
|
||||
"default is not valid on type: permissioneditor")
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
|
||||
"mode is only valid on type: fileupload, datepicker or permissioneditor")
|
||||
if err := rosterBoot(t, rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})); err != nil {
|
||||
t.Fatalf("mode: checkbox did not boot: %v", err)
|
||||
}
|
||||
// A controller without the provider cannot have the field.
|
||||
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
|
||||
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
if err := compileDateFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compilePermissionFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileExtension(item.plugin.ID(), compiled, fsys.AdminFS()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ var relationFormRefusedTypes = map[string]bool{
|
||||
"relation": true, "relation-manager": true, "widget": true, "partial": true,
|
||||
// A password is a virtual field, and only an admin controller lists those.
|
||||
"password": true,
|
||||
// A permission editor's options and storage belong to an admin controller.
|
||||
"permissioneditor": true,
|
||||
}
|
||||
|
||||
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].
|
||||
|
||||
@@ -257,8 +257,9 @@ type FormField struct {
|
||||
// Path names the controller partial of a `type: partial` field: the
|
||||
// template {ConfigDir}/_{path}.htm (D-09).
|
||||
Path string `json:"path,omitempty"`
|
||||
// Mode is the fileupload mode (image or file, default file) or the
|
||||
// datepicker mode (date, datetime or time, default datetime).
|
||||
// Mode is the fileupload mode (image or file, default file), the
|
||||
// datepicker mode (date, datetime or time, default datetime) or the
|
||||
// permissioneditor mode (radio or checkbox).
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Format is a datepicker's WinterCMS (PHP date) display format;
|
||||
// DisplayFormat is the same format in the SPA's moment-style tokens.
|
||||
@@ -304,6 +305,10 @@ type FormField struct {
|
||||
// Preset makes a text field follow another field of the form while the
|
||||
// administrator has not edited it, on create only (fields.yaml preset).
|
||||
Preset *FieldPreset `json:"preset,omitempty"`
|
||||
// PermissionOptions are the permissions a `type: permissioneditor` field
|
||||
// offers the requesting administrator, in display order. They are filled
|
||||
// per request by the form schema route.
|
||||
PermissionOptions []PermissionOption `json:"permissionOptions,omitempty"`
|
||||
|
||||
optionsMethod string
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
|
||||
}
|
||||
for _, field := range fields {
|
||||
// A settings screen has no admin controller to own actions or view models.
|
||||
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" {
|
||||
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" || field.Type == permissionFieldType {
|
||||
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
|
||||
}
|
||||
if field.Preset != nil {
|
||||
|
||||
10
modules/cabana/testdata/roster/lang/en/lang.yaml
vendored
10
modules/cabana/testdata/roster/lang/en/lang.yaml
vendored
@@ -28,3 +28,13 @@ people:
|
||||
password: Password
|
||||
password_confirmation: Repeat the password
|
||||
notify: Send a welcome message
|
||||
permissions: Permissions
|
||||
tab_permissions: Permissions
|
||||
permissions:
|
||||
tab_content: Content
|
||||
tab_reports: Reports
|
||||
posts_edit: Edit posts
|
||||
posts_edit_comment: Change the text of any post.
|
||||
posts_publish: Publish posts
|
||||
reports_export: Export reports
|
||||
misc_beta: Try beta features
|
||||
|
||||
10
modules/cabana/testdata/roster/lang/pl/lang.yaml
vendored
10
modules/cabana/testdata/roster/lang/pl/lang.yaml
vendored
@@ -28,3 +28,13 @@ people:
|
||||
password: Hasło
|
||||
password_confirmation: Powtórz hasło
|
||||
notify: Wyślij wiadomość powitalną
|
||||
permissions: Uprawnienia
|
||||
tab_permissions: Uprawnienia
|
||||
permissions:
|
||||
tab_content: Treści
|
||||
tab_reports: Raporty
|
||||
posts_edit: Edycja wpisów
|
||||
posts_edit_comment: Zmiana treści dowolnego wpisu.
|
||||
posts_publish: Publikowanie wpisów
|
||||
reports_export: Eksport raportów
|
||||
misc_beta: Funkcje beta
|
||||
|
||||
@@ -26,6 +26,12 @@ fields:
|
||||
type: checkbox
|
||||
default: true
|
||||
context: create
|
||||
permissions:
|
||||
label: acme.roster::lang.people.permissions
|
||||
type: permissioneditor
|
||||
mode: radio
|
||||
tab: acme.roster::lang.people.tab_permissions
|
||||
context: update
|
||||
joined_ip:
|
||||
label: acme.roster::lang.people.joined_ip
|
||||
type: text
|
||||
|
||||
@@ -220,3 +220,10 @@ messages:
|
||||
update_submit: Save record
|
||||
pivot_submit: Save link details
|
||||
link_submit: Add link
|
||||
permissioneditor:
|
||||
allow: Allow
|
||||
inherit: Inherit
|
||||
deny: Deny
|
||||
locked: You cannot change this permission.
|
||||
empty: No permissions are defined yet.
|
||||
other: Other
|
||||
|
||||
@@ -242,3 +242,10 @@ messages:
|
||||
update_submit: Zapisz rekord
|
||||
pivot_submit: Zapisz szczegóły powiązania
|
||||
link_submit: Dodaj powiązanie
|
||||
permissioneditor:
|
||||
allow: Zezwól
|
||||
inherit: Dziedzicz
|
||||
deny: Odmów
|
||||
locked: Nie możesz zmienić tego uprawnienia.
|
||||
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
|
||||
other: Inne
|
||||
|
||||
Reference in New Issue
Block a user