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 true // posts.edit false // posts.publish false // reports.export true // {"posts.edit":1,"posts.publish":-1} 2 }