- 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
170 lines
6.4 KiB
Go
170 lines
6.4 KiB
Go
package cabana_test
|
|
|
|
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"
|
|
)
|
|
|
|
// Member is the model behind the acme.roster members controller. Password
|
|
// holds a hash; the form never reads or writes the column itself.
|
|
type Member struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
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" }
|
|
|
|
// Fillable lists the columns the admin form may write.
|
|
func (Member) Fillable() []string { return []string{"name", "slug"} }
|
|
|
|
// Rules are the model's own rules, used wherever the controller sets none.
|
|
func (Member) Rules() map[string]string {
|
|
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
|
|
}
|
|
|
|
// MembersController is an admin controller whose form has fields that are
|
|
// not columns and rules of its own.
|
|
type MembersController struct{}
|
|
|
|
var (
|
|
_ pact.AdminController = MembersController{}
|
|
_ pact.AdminRecordSource = MembersController{}
|
|
_ pact.FormVirtualFields = MembersController{}
|
|
_ pact.FormRules = MembersController{}
|
|
_ pact.FormBeforeCreate = MembersController{}
|
|
_ pact.FormBeforeUpdate = MembersController{}
|
|
|
|
_ cabana.PermissionEditorProvider = MembersController{}
|
|
)
|
|
|
|
func (MembersController) ID() string { return "acme.roster.members" }
|
|
func (MembersController) ModelName() string { return "Member" }
|
|
func (MembersController) ConfigDir() string { return "controllers/members" }
|
|
func (MembersController) NewRecord() any { return &Member{} }
|
|
|
|
// FormVirtualFields names the fields of fields.yaml that are not columns of
|
|
// the form. cabana never fills or returns them.
|
|
func (MembersController) FormVirtualFields() []string {
|
|
return []string{"password", "password_confirmation", "notify"}
|
|
}
|
|
|
|
// FormRules replaces the model's rules for admin saves: a create needs a
|
|
// password, an update takes one only when the administrator types it.
|
|
func (MembersController) FormRules(_ context.Context, op string) map[string]string {
|
|
rules := map[string]string{"name": "required"}
|
|
if op == "create" {
|
|
rules["password"] = "required|between:8,255|confirmed"
|
|
} else {
|
|
rules["password"] = "nullable|between:8,255|confirmed"
|
|
}
|
|
return rules
|
|
}
|
|
|
|
// FormBeforeCreate reads the submitted virtual values, which have passed the
|
|
// rules by now, and stores what the model needs.
|
|
func (MembersController) FormBeforeCreate(ctx context.Context, model any) error {
|
|
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
|
member := model.(*Member)
|
|
if plain, ok := values["password"].(string); ok && plain != "" {
|
|
member.Password = hashPassword(plain)
|
|
}
|
|
if notify, _ := values["notify"].(bool); notify {
|
|
// Queue the welcome message here.
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormBeforeUpdate changes the password only when one was submitted.
|
|
func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error {
|
|
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
|
if plain, ok := values["password"].(string); ok && plain != "" {
|
|
model.(*Member).Password = hashPassword(plain)
|
|
}
|
|
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))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Example_formSeams shows what the controller declares. Outside a save there
|
|
// are no submitted values, so the hook stores nothing.
|
|
func Example_formSeams() {
|
|
ctl := MembersController{}
|
|
ctx := context.Background()
|
|
fmt.Println(ctl.FormVirtualFields())
|
|
fmt.Println("create:", ctl.FormRules(ctx, "create")["password"])
|
|
fmt.Println("update:", ctl.FormRules(ctx, "update")["password"])
|
|
|
|
member := &Member{Name: "Ada"}
|
|
_, 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
|
|
}
|