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:
Jakub Zych
2026-10-05 10:44:50 +02:00
parent a1c6bb1ce6
commit f50d9b8f10
38 changed files with 1300 additions and 34 deletions

View File

@@ -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
}