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" "gorm.io/gorm" ) // 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"` // OrganisationID is a protected column: the form writes it only through // the team relation field. OrganisationID *uint `gorm:"column:organisation_id"` } // Team is what a member belongs to; Group is what a member is put into. type Team struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` } type Group struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` Code string `gorm:"column:code"` } type MemberGroup struct { MemberID uint `gorm:"column:member_id;primaryKey"` GroupID uint `gorm:"column:group_id;primaryKey"` } 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. DB is the application's database handle, // for reads outside a save. type MembersController struct { DB *gorm.DB } var ( _ pact.AdminController = MembersController{} _ pact.AdminRecordSource = MembersController{} _ pact.FormVirtualFields = MembersController{} _ pact.FormRules = MembersController{} _ pact.FormBeforeCreate = MembersController{} _ pact.FormBeforeUpdate = MembersController{} _ cabana.PermissionEditorProvider = MembersController{} _ cabana.FieldRelationProvider = MembersController{} _ cabana.RelationLockProvider = 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 } // AdminFieldRelations binds the form's two relation fields. organisation_id // is a protected column, so the team field would be read-only; the contract // opts in to writing it through this field. func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{{ Field: "team", Kind: "belongsTo", NewRelated: func() any { return &Team{} }, ForeignKey: "organisation_id", WritableForeignKey: true, }, { Field: "groups", Kind: "belongsToMany", NewRelated: func() any { return &Group{} }, NewPivot: func() any { return &MemberGroup{} }, ParentForeignKey: "member_id", RelatedForeignKey: "group_id", }} } // AdminRelationLocks names the groups an administrator without // acme.roster.manage may not put a member into or take a member out of. Inside // a save the ids are read with the save's transaction. func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) { principal, _ := bouncer.User(ctx) if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) { return cabana.RelationLock{}, nil } db := c.DB if tx, ok := cabana.TxFromContext(ctx); ok { db = tx } lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"} err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error return lock, err } // 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)) // The relation fields: team writes a protected key, groups has locks. for _, contract := range ctl.AdminFieldRelations() { fmt.Println(contract.Field, contract.Kind, contract.WritableForeignKey) } // The team field has no locks; the groups field would read them from // the database. lock, err := ctl.AdminRelationLocks(ctx, "team") fmt.Println(len(lock.IDs), err) // 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 // team belongsTo true // groups belongsToMany false // 0 }