feat: User Groups admin screen with the users count and privileged codes protected
- controller golem15.user.usergroups behind golem15.users.access_groups - list with users_count from the list's own query; form with the checkbox permission editor stored in user_groups.permissions - creating a group with a privileged code, changing a code to or from one and deleting a privileged group need golem15.users.manage_privileged_groups - a group delete removes its users_groups rows in the same transaction - UserGroup gains Fillable, Rules and the read-only UsersCount; PermissionSet.Text - TestAdminGroups and the D-06 matrix in TestAdminPrivilegedGroups
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
usersAdminController{app: app},
|
||||
usergroupsAdminController{app: appAccess(app)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +37,14 @@ func init() {
|
||||
var _ cabana.FieldRelationProvider = usersAdminController{}
|
||||
var _ cabana.RelationLockProvider = usersAdminController{}
|
||||
var _ cabana.PermissionEditorProvider = usersAdminController{}
|
||||
|
||||
var _ pact.AdminController = usergroupsAdminController{}
|
||||
var _ pact.AdminPermissioned = usergroupsAdminController{}
|
||||
var _ pact.AdminRecordSource = usergroupsAdminController{}
|
||||
var _ pact.ListExtendQuery = usergroupsAdminController{}
|
||||
var _ pact.FormBeforeCreate = usergroupsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = usergroupsAdminController{}
|
||||
var _ pact.FormBeforeDelete = usergroupsAdminController{}
|
||||
var _ pact.FormAfterDelete = usergroupsAdminController{}
|
||||
var _ cabana.PermissionEditorProvider = usergroupsAdminController{}
|
||||
}
|
||||
|
||||
47
controllers/app_access.go
Normal file
47
controllers/app_access.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// appAccess resolves the booted application on every call, so an admin
|
||||
// controller never keeps a database handle or a config of its own.
|
||||
type appAccess func() *backpack.App
|
||||
|
||||
// application returns the booted app, or nil before boot.
|
||||
func (a appAccess) application() *backpack.App {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return a()
|
||||
}
|
||||
|
||||
// db is the application pool, or nil when the app has none.
|
||||
func (a appAccess) db() *gorm.DB {
|
||||
app := a.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
// config is the application config, or nil before boot.
|
||||
func (a appAccess) config() *compass.Config {
|
||||
app := a.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Config
|
||||
}
|
||||
|
||||
// translator is the application translator, or nil when none is published.
|
||||
func (a appAccess) translator() *phrasebook.Translator {
|
||||
return appTranslator(a.application())
|
||||
}
|
||||
19
controllers/usergroups/config_form.yaml
Normal file
19
controllers/usergroups/config_form.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: golem15.user::lang.group.label
|
||||
form: ~/plugins/golem15/user/models/usergroup/fields.yaml
|
||||
modelClass: Golem15\User\Models\UserGroup
|
||||
defaultRedirect: golem15/user/usergroups
|
||||
|
||||
create:
|
||||
redirect: golem15/user/usergroups/update/:id
|
||||
redirectClose: golem15/user/usergroups
|
||||
|
||||
update:
|
||||
redirect: golem15/user/usergroups
|
||||
redirectClose: golem15/user/usergroups
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.groups.new_group
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleteConfirm: golem15.user::lang.groups.delete_confirm
|
||||
deleted: backend::lang.messages.form.deleted
|
||||
17
controllers/usergroups/config_list.yaml
Normal file
17
controllers/usergroups/config_list.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
list: ~/plugins/golem15/user/models/usergroup/columns.yaml
|
||||
modelClass: Golem15\User\Models\UserGroup
|
||||
title: golem15.user::lang.groups.list_title
|
||||
recordUrl: golem15/user/usergroups/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
showSetup: true
|
||||
showSorting: true
|
||||
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.groups.new_group
|
||||
empty: golem15.user::lang.groups.list_empty
|
||||
246
controllers/usergroups_admin_controller.go
Normal file
246
controllers/usergroups_admin_controller.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PermissionAccessGroups is the backend permission of the User Groups
|
||||
// screen, the PHP UserGroups::$requiredPermissions.
|
||||
const PermissionAccessGroups = "golem15.users.access_groups"
|
||||
|
||||
// groupsLang prefixes the phrase keys of the User Groups screen.
|
||||
const groupsLang = "golem15.user::lang.groups."
|
||||
|
||||
// groupCodePattern is the PHP UserGroup code rule
|
||||
// regex:/^[a-zA-Z0-9_\-]+$/. lagoon.Validate has no regex token, so the
|
||||
// Form before-hooks check it.
|
||||
var groupCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// usergroupsAdminController is the Winter UserGroups backend controller: the
|
||||
// list with the member count and the form with the frontend permissions of
|
||||
// the group. The screen and every write need golem15.users.access_groups.
|
||||
//
|
||||
// A group's code decides whether its members are privileged
|
||||
// (golem15.user.privileged_groups), so three writes additionally need
|
||||
// golem15.users.manage_privileged_groups: creating a group with a privileged
|
||||
// code, changing a code to or from a privileged one, and deleting a
|
||||
// privileged group. Everything else on a privileged group (its name,
|
||||
// description and frontend permissions) needs only the screen's permission.
|
||||
//
|
||||
// The screen never writes a membership: users_groups changes only through
|
||||
// the user form's groups field, and loses the rows of a deleted group.
|
||||
type usergroupsAdminController struct {
|
||||
app appAccess
|
||||
}
|
||||
|
||||
func (usergroupsAdminController) ID() string { return "golem15.user.usergroups" }
|
||||
|
||||
func (usergroupsAdminController) ModelName() string { return `Golem15\User\Models\UserGroup` }
|
||||
|
||||
func (usergroupsAdminController) ConfigDir() string { return "controllers/usergroups" }
|
||||
|
||||
func (usergroupsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessGroups}
|
||||
}
|
||||
|
||||
func (usergroupsAdminController) NewRecord() any { return &models.UserGroup{} }
|
||||
|
||||
// ListExtendQuery adds the member count of every group on the page, the PHP
|
||||
// users_count relation column, in the list's own query. The two select
|
||||
// arguments keep the list's count query a plain COUNT(*).
|
||||
func (usergroupsAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
return db.Select("user_groups.*",
|
||||
"(SELECT COUNT(*) FROM users_groups WHERE users_groups.user_group_id = user_groups.id) AS users_count")
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks the code's format and refuses a privileged code
|
||||
// without golem15.users.manage_privileged_groups.
|
||||
func (c usergroupsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
group, ok := model.(*models.UserGroup)
|
||||
if !ok || group == nil {
|
||||
return errors.New("user groups admin: form model is not a user group")
|
||||
}
|
||||
if err := c.requireCodeFormat(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
if !classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
|
||||
return nil
|
||||
}
|
||||
return c.requirePrivilegedPermission(ctx)
|
||||
}
|
||||
|
||||
// FormBeforeUpdate checks the code's format and refuses a code change to or
|
||||
// from a privileged code without golem15.users.manage_privileged_groups. The
|
||||
// stored code is read through the write's transaction; a save that leaves
|
||||
// the code as it is passes, whatever the code.
|
||||
func (c usergroupsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
group, ok := model.(*models.UserGroup)
|
||||
if !ok || group == nil {
|
||||
return errors.New("user groups admin: form model is not a user group")
|
||||
}
|
||||
if err := c.requireCodeFormat(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
db := requestDB(ctx, c.app.db)
|
||||
if db == nil {
|
||||
return errors.New("user groups admin: gorm handle is missing")
|
||||
}
|
||||
var stored []struct{ Code *string }
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
Table("user_groups").Select("code").Where("id = ?", group.ID).Scan(&stored).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
return errors.New("user groups admin: the group being updated was not found")
|
||||
}
|
||||
if sameCode(stored[0].Code, group.Code) {
|
||||
return nil
|
||||
}
|
||||
codes := classes.PrivilegedGroupCodes(c.app.config())
|
||||
if !classes.IsPrivilegedCode(codes, stored[0].Code) && !classes.IsPrivilegedCode(codes, group.Code) {
|
||||
return nil
|
||||
}
|
||||
return c.requirePrivilegedPermission(ctx)
|
||||
}
|
||||
|
||||
// FormBeforeDelete refuses the delete of a privileged group without
|
||||
// golem15.users.manage_privileged_groups: it would take the privilege from
|
||||
// every member. The record is the stored row, loaded in the delete's
|
||||
// transaction.
|
||||
func (c usergroupsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||
group, ok := model.(*models.UserGroup)
|
||||
if !ok || group == nil {
|
||||
return errors.New("user groups admin: form model is not a user group")
|
||||
}
|
||||
if !classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
|
||||
return nil
|
||||
}
|
||||
return c.requirePrivilegedPermission(ctx)
|
||||
}
|
||||
|
||||
// FormAfterDelete removes the memberships of the deleted group in the same
|
||||
// transaction: users_groups has no foreign key that would do it. The member
|
||||
// users stay.
|
||||
func (usergroupsAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
||||
group, ok := model.(*models.UserGroup)
|
||||
if !ok || group == nil {
|
||||
return errors.New("user groups admin: form model is not a user group")
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("user groups admin: a delete needs its transaction")
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error
|
||||
}
|
||||
|
||||
// requirePrivilegedPermission answers nil when the administrator on ctx
|
||||
// holds golem15.users.manage_privileged_groups, else the 403 of the screen.
|
||||
// A context without a principal is refused.
|
||||
func (usergroupsAdminController) requirePrivilegedPermission(ctx context.Context) error {
|
||||
principal, _ := bouncer.User(ctx)
|
||||
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
|
||||
return nil
|
||||
}
|
||||
const message = groupsLang + "privileged_forbidden"
|
||||
return &cabana.ForbiddenError{Message: message, Details: map[string]any{"code": []string{message}}}
|
||||
}
|
||||
|
||||
// requireCodeFormat answers 422 on code when it holds anything but letters,
|
||||
// digits, underscore and hyphen. An empty code is left to the required rule.
|
||||
func (c usergroupsAdminController) requireCodeFormat(ctx context.Context, group *models.UserGroup) error {
|
||||
if group.Code == nil || *group.Code == "" || groupCodePattern.MatchString(*group.Code) {
|
||||
return nil
|
||||
}
|
||||
message := "The code may only contain letters, numbers, dashes and underscores."
|
||||
if tr := c.app.translator(); tr != nil {
|
||||
const key = "golem15.user::lang.group.code_invalid"
|
||||
if text := tr.Get(ctx, key, nil); text != "" && text != key {
|
||||
message = text
|
||||
}
|
||||
}
|
||||
return &cabana.ValidationError{Details: map[string]any{"code": []string{message}}}
|
||||
}
|
||||
|
||||
// sameCode compares two nullable group codes, case-sensitively.
|
||||
func sameCode(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// AdminPermissionOptions offers the rows of
|
||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
||||
func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
if field != permissionsField {
|
||||
return nil, fmt.Errorf("user groups admin: unknown permission field %q", field)
|
||||
}
|
||||
db := requestDB(ctx, c.app.db)
|
||||
if db == nil {
|
||||
return nil, errors.New("user groups admin: gorm handle is missing")
|
||||
}
|
||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||
if row.Comment != nil {
|
||||
option.Comment = *row.Comment
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues returns the group's stored permissions, read with
|
||||
// the tolerance of models.ParsePermissionSet.
|
||||
func (usergroupsAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
||||
group, ok := record.(*models.UserGroup)
|
||||
if field != permissionsField || !ok || group == nil {
|
||||
return nil, fmt.Errorf("user groups admin: cannot read permission field %q", field)
|
||||
}
|
||||
if group.Permissions == nil {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
set, err := models.ParsePermissionSet([]byte(*group.Permissions))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]int, len(set))
|
||||
for code, value := range set {
|
||||
values[code] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues writes the validated set back as the JSON object
|
||||
// PHP stores (checkbox mode: 1 per allowed code), or NULL for an empty set.
|
||||
// The save writes the row.
|
||||
func (usergroupsAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
|
||||
group, ok := record.(*models.UserGroup)
|
||||
if field != permissionsField || !ok || group == nil {
|
||||
return fmt.Errorf("user groups admin: cannot write permission field %q", field)
|
||||
}
|
||||
set := make(models.PermissionSet, len(values))
|
||||
for code, value := range values {
|
||||
set[code] = value
|
||||
}
|
||||
group.Permissions = set.Text()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user