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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user