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:
1
admin.go
1
admin.go
@@ -13,6 +13,7 @@ import (
|
||||
// directory also holds files that are not admin assets.
|
||||
//
|
||||
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml controllers/users/_hint.htm models/user/fields.yaml models/user/columns.yaml
|
||||
//go:embed controllers/usergroups/config_list.yaml controllers/usergroups/config_form.yaml models/usergroup/fields.yaml models/usergroup/columns.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
405
admin_groups_test.go
Normal file
405
admin_groups_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
)
|
||||
|
||||
const (
|
||||
// adminGroupsPath is the User Groups controller under the admin API.
|
||||
adminGroupsPath = "/golem15/user/usergroups"
|
||||
|
||||
permAccessGroups = "golem15.users.access_groups"
|
||||
|
||||
privilegedGroupCodeForbidden = "You do not have permission to create, re-code or delete a privileged group. Nothing was saved."
|
||||
)
|
||||
|
||||
// groupPath is the admin API path of one user group.
|
||||
func groupPath(id uint) string {
|
||||
return adminAPI(adminGroupsPath) + "/" + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// storedGroup is a user_groups row as the database holds it.
|
||||
type storedGroup struct {
|
||||
Name string
|
||||
Code *string
|
||||
Description *string
|
||||
Permissions *string
|
||||
}
|
||||
|
||||
// group reads a user_groups row; nil when it is gone.
|
||||
func (e *adminEnv) group(t *testing.T, id uint) *storedGroup {
|
||||
t.Helper()
|
||||
var rows []storedGroup
|
||||
if err := e.gdb.Raw(`SELECT name, code, description, permissions FROM user_groups WHERE id = ?`, id).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &rows[0]
|
||||
}
|
||||
|
||||
// sideMenu returns the side item codes of the navigation item `user` as the
|
||||
// administrator sees them; nil when the item is not shown.
|
||||
func (e *adminEnv) sideMenu(t *testing.T, token string) []string {
|
||||
t.Helper()
|
||||
var nav []struct {
|
||||
Code string `json:"code"`
|
||||
SideMenu []struct {
|
||||
Code string `json:"code"`
|
||||
Controller string `json:"controller"`
|
||||
} `json:"sideMenu"`
|
||||
}
|
||||
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav)
|
||||
for _, item := range nav {
|
||||
if item.Code != "user" {
|
||||
continue
|
||||
}
|
||||
codes := []string{}
|
||||
for _, side := range item.SideMenu {
|
||||
codes = append(codes, side.Code+"="+side.Controller)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestAdminGroups covers the User Groups screen (D-06 ordinary path, D-15,
|
||||
// D-16, D-24, D-29): its permission, the list with the member count, the
|
||||
// form with the code rules and the checkbox permission editor, and the
|
||||
// delete with its pivot cleanup.
|
||||
func TestAdminGroups(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
usersOnly := env.admin(t, permAccessUsers)
|
||||
token := env.admin(t, permAccessGroups)
|
||||
stamp := adminStamp()
|
||||
|
||||
// The screen needs golem15.users.access_groups: the users permission
|
||||
// alone opens nothing here and does not show the side item.
|
||||
for _, path := range []string{adminAPI(adminGroupsPath), adminAPI(adminGroupsPath + "/schema/list"), adminAPI(adminGroupsPath + "/schema/form")} {
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, usersOnly, nil)
|
||||
if code := adminDecode(t, rec).Error.Code; code != "forbidden" {
|
||||
t.Fatalf("%s code = %q", path, code)
|
||||
}
|
||||
}
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, adminAPI(adminGroupsPath), usersOnly, map[string]any{"name": "Nope " + stamp, "code": "nope-" + stamp})
|
||||
if got := env.sideMenu(t, usersOnly); !reflect.DeepEqual(got, []string{"users=golem15.user.users"}) {
|
||||
t.Fatalf("side menu with the users permission = %v", got)
|
||||
}
|
||||
if got := env.sideMenu(t, token); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
|
||||
t.Fatalf("side menu with the groups permission = %v", got)
|
||||
}
|
||||
// The users screen stays closed to the groups permission.
|
||||
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath), token, nil)
|
||||
|
||||
// The list schema: Name, Code, Created and Users; rows open the update
|
||||
// form; no checkboxes, no bulk actions, no delete in the toolbar.
|
||||
var schema struct {
|
||||
RecordURL string `json:"recordUrl"`
|
||||
ShowCheckboxes bool `json:"showCheckboxes"`
|
||||
Columns []struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Invisible bool `json:"invisible"`
|
||||
Sortable bool `json:"sortable"`
|
||||
Type string `json:"type"`
|
||||
} `json:"columns"`
|
||||
ToolbarButtons []string `json:"toolbarButtons"`
|
||||
BulkActions []json.RawMessage `json:"bulkActions"`
|
||||
Messages map[string]map[string]string `json:"messages"`
|
||||
}
|
||||
rawSchema := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath+"/schema/list"), token, nil)
|
||||
adminInto(t, rawSchema, &schema)
|
||||
var visible []string
|
||||
for _, column := range schema.Columns {
|
||||
if column.Invisible {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, column.Key+":"+column.Label)
|
||||
if column.Key == "users_count" && column.Sortable {
|
||||
t.Fatal("users_count is sortable")
|
||||
}
|
||||
}
|
||||
if want := []string{"name:Name", "code:Code", "created_at:Created", "users_count:Users"}; !reflect.DeepEqual(visible, want) {
|
||||
t.Fatalf("visible columns = %v, want %v", visible, want)
|
||||
}
|
||||
if schema.RecordURL != "golem15/user/usergroups/update/:id" || schema.ShowCheckboxes || len(schema.BulkActions) != 0 ||
|
||||
!reflect.DeepEqual(schema.ToolbarButtons, []string{"create"}) {
|
||||
t.Fatalf("list schema = %s", rawSchema.Body.String())
|
||||
}
|
||||
if got := schema.Messages["empty"]["other"]; got != "No user groups yet. Create the first one with New Group." {
|
||||
t.Fatalf("empty message = %q", got)
|
||||
}
|
||||
if got := schema.Messages["create"]["other"]; got != "New Group" {
|
||||
t.Fatalf("create message = %q", got)
|
||||
}
|
||||
// Without checkboxes there is no bulk delete on this screen.
|
||||
if rec := env.call(t, http.MethodPost, adminAPI(adminGroupsPath+"/bulk-delete"), token, map[string]any{"ids": []uint{1}}); rec.Code == http.StatusOK {
|
||||
t.Fatalf("bulk delete of groups = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The list carries the member count from its own query: 0 for a group
|
||||
// without members (a number, not an absent value), the count otherwise.
|
||||
empty := env.seedGroup(t, "Empty "+stamp, "empty-"+stamp)
|
||||
pair := env.seedGroup(t, "Pair "+stamp, "pair-"+stamp)
|
||||
single := env.seedGroup(t, "Single "+stamp, "single-"+stamp)
|
||||
alice := env.seedUser(t, "alice", nil)
|
||||
bob := env.seedUser(t, "bob", nil)
|
||||
env.join(t, alice, pair, single)
|
||||
env.join(t, bob, pair)
|
||||
list := func(query string) (map[uint]map[string]any, int) {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath)+"?"+query, token, nil)
|
||||
var rows []map[string]any
|
||||
answer := adminInto(t, rec, &rows)
|
||||
var meta struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(answer.Meta, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[uint]map[string]any{}
|
||||
for _, row := range rows {
|
||||
out[uint(row["id"].(float64))] = row
|
||||
}
|
||||
return out, meta.Total
|
||||
}
|
||||
rows, total := list("search=" + stamp)
|
||||
if total != 3 || len(rows) != 3 {
|
||||
t.Fatalf("list total = %d rows = %d", total, len(rows))
|
||||
}
|
||||
for id, want := range map[uint]float64{empty.ID: 0, pair.ID: 2, single.ID: 1} {
|
||||
got, ok := rows[id]["users_count"].(float64)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("users_count of %d = %v, want %v", id, rows[id]["users_count"], want)
|
||||
}
|
||||
}
|
||||
if rows[pair.ID]["name"] != "Pair "+stamp || rows[pair.ID]["code"] != "pair-"+stamp {
|
||||
t.Fatalf("list row = %v", rows[pair.ID])
|
||||
}
|
||||
// Paging counts groups, not memberships.
|
||||
if _, total = list("search=" + stamp + "&per_page=20&page=1&sort=name&dir=asc"); total != 3 {
|
||||
t.Fatalf("sorted list total = %d", total)
|
||||
}
|
||||
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminGroupsPath)+"?sort=users_count", token, nil)
|
||||
|
||||
// The form: name, code (preset from name), description and the checkbox
|
||||
// permission editor on the Permissions tab.
|
||||
read, write := "wiki."+stamp+".read", "wiki."+stamp+".write"
|
||||
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Read', ?), (?, 'Write', ?)`,
|
||||
read, "Wiki "+stamp, write, "Wiki "+stamp).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var form struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Mode string `json:"mode"`
|
||||
Tab string `json:"tab"`
|
||||
Preset *struct {
|
||||
Field string `json:"field"`
|
||||
} `json:"preset"`
|
||||
Options []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"permissionOptions"`
|
||||
} `json:"fields"`
|
||||
Preview json.RawMessage `json:"preview"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath+"/schema/form"), token, nil), &form)
|
||||
var names []string
|
||||
for _, field := range form.Fields {
|
||||
names = append(names, field.Name+":"+field.Type)
|
||||
switch field.Name {
|
||||
case "code":
|
||||
if field.Preset == nil || field.Preset.Field != "name" {
|
||||
t.Fatalf("code preset = %+v", field.Preset)
|
||||
}
|
||||
case "permissions":
|
||||
offered := 0
|
||||
for _, option := range field.Options {
|
||||
if option.Code == read || option.Code == write {
|
||||
offered++
|
||||
}
|
||||
}
|
||||
if field.Mode != "checkbox" || field.Tab != "Permissions" || offered != 2 {
|
||||
t.Fatalf("permissions field = %+v", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{"name:text", "code:text", "description:textarea", "permissions:permissioneditor"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("form fields = %v, want %v", names, want)
|
||||
}
|
||||
if len(form.Preview) != 0 && string(form.Preview) != "null" {
|
||||
t.Fatalf("the group form has a preview: %s", form.Preview)
|
||||
}
|
||||
|
||||
// Create: the code needs the PHP format and must be unique; the name
|
||||
// needs three characters.
|
||||
create := func(status int, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return env.expect(t, status, http.MethodPost, adminAPI(adminGroupsPath), token, body)
|
||||
}
|
||||
code := "editors-" + stamp
|
||||
for field, body := range map[string]map[string]any{
|
||||
"code": {"name": "Editors " + stamp, "code": "bad code " + stamp},
|
||||
"name": {"name": "ab", "code": "short-" + stamp},
|
||||
} {
|
||||
rec := create(http.StatusUnprocessableEntity, body)
|
||||
if details := adminDecode(t, rec).Error.Details; len(details[field]) == 0 {
|
||||
t.Fatalf("create refusal has no detail on %s: %s", field, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"dot." + stamp, "ąę" + stamp, "semi;" + stamp} {
|
||||
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Editors " + stamp, "code": bad})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["code"]) != 1 || details["code"][0] != "The code may only contain letters, numbers, dashes and underscores." {
|
||||
t.Fatalf("code refusal for %q = %s", bad, rec.Body.String())
|
||||
}
|
||||
}
|
||||
create(http.StatusUnprocessableEntity, map[string]any{"name": "No code " + stamp})
|
||||
var before int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE name LIKE ?`, "%"+stamp).Scan(&before).Error; err != nil || before != 3 {
|
||||
t.Fatalf("refused creates left rows: %d (%v)", before, err)
|
||||
}
|
||||
id := createdID(t, create(http.StatusCreated, map[string]any{"name": "Editors " + stamp, "code": code, "description": "May edit"}))
|
||||
if got := env.group(t, id); got == nil || got.Name != "Editors "+stamp || got.Code == nil || *got.Code != code ||
|
||||
got.Description == nil || *got.Description != "May edit" || got.Permissions != nil {
|
||||
t.Fatalf("created group = %+v", got)
|
||||
}
|
||||
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Editors again " + stamp, "code": code})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["code"]) == 0 {
|
||||
t.Fatalf("duplicate code refusal = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Update: name and description; the group's own code sent back is not a
|
||||
// duplicate; a bad code is refused and changes nothing.
|
||||
update := func(status int, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return env.expect(t, status, http.MethodPut, groupPath(id), token, body)
|
||||
}
|
||||
update(http.StatusOK, map[string]any{"name": "Editors renamed " + stamp, "code": code, "description": "Edits the wiki"})
|
||||
if got := env.group(t, id); got.Name != "Editors renamed "+stamp || *got.Code != code || *got.Description != "Edits the wiki" {
|
||||
t.Fatalf("updated group = %+v", got)
|
||||
}
|
||||
update(http.StatusUnprocessableEntity, map[string]any{"name": "Broken " + stamp, "code": "bad code"})
|
||||
update(http.StatusUnprocessableEntity, map[string]any{"name": "Broken " + stamp, "code": "pair-" + stamp})
|
||||
if got := env.group(t, id); got.Name != "Editors renamed "+stamp || *got.Code != code {
|
||||
t.Fatalf("a refused update changed the group: %+v", got)
|
||||
}
|
||||
// An ordinary code changes freely.
|
||||
code = "wiki-editors-" + stamp
|
||||
update(http.StatusOK, map[string]any{"name": "Editors renamed " + stamp, "code": code})
|
||||
if got := env.group(t, id); *got.Code != code {
|
||||
t.Fatalf("code after the change = %q", *got.Code)
|
||||
}
|
||||
base := map[string]any{"name": "Editors renamed " + stamp, "code": code}
|
||||
save := func(status int, permissions any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := map[string]any{"permissions": permissions}
|
||||
for key, value := range base {
|
||||
body[key] = value
|
||||
}
|
||||
return update(status, body)
|
||||
}
|
||||
storedPermissions := func() map[string]int {
|
||||
t.Helper()
|
||||
got := env.group(t, id)
|
||||
if got.Permissions == nil {
|
||||
return nil
|
||||
}
|
||||
var decoded map[string]int
|
||||
if err := json.Unmarshal([]byte(*got.Permissions), &decoded); err != nil {
|
||||
t.Fatalf("stored permissions %q: %v", *got.Permissions, err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Checkbox mode stores a JSON object of code to 1.
|
||||
save(http.StatusOK, map[string]int{read: 1, write: 1})
|
||||
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1, write: 1}) {
|
||||
t.Fatalf("stored permissions = %v", got)
|
||||
}
|
||||
var record struct {
|
||||
Permissions map[string]int `json:"permissions"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, groupPath(id), token, nil), &record)
|
||||
if !reflect.DeepEqual(record.Permissions, map[string]int{read: 1, write: 1}) {
|
||||
t.Fatalf("record permissions = %v", record.Permissions)
|
||||
}
|
||||
// A deny has no place in checkbox mode; neither has an unknown code.
|
||||
for _, bad := range []any{map[string]int{read: -1}, map[string]int{read: 2}, map[string]int{"unknown." + stamp: 1}, "all"} {
|
||||
rec := save(http.StatusUnprocessableEntity, bad)
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["permissions"]) == 0 {
|
||||
t.Fatalf("permission refusal of %v = %s", bad, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1, write: 1}) {
|
||||
t.Fatalf("a refused save changed the permissions: %v", got)
|
||||
}
|
||||
// A save without the field leaves them alone; unchecking removes the
|
||||
// entry; an empty object stores NULL.
|
||||
update(http.StatusOK, base)
|
||||
if got := storedPermissions(); len(got) != 2 {
|
||||
t.Fatalf("a save without the field changed the permissions: %v", got)
|
||||
}
|
||||
save(http.StatusOK, map[string]int{read: 1, write: 0})
|
||||
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1}) {
|
||||
t.Fatalf("permissions after unchecking = %v", got)
|
||||
}
|
||||
save(http.StatusOK, map[string]int{})
|
||||
if got := env.group(t, id); got.Permissions != nil {
|
||||
t.Fatalf("cleared permissions = %q", *got.Permissions)
|
||||
}
|
||||
// A stored code that is no longer offered survives an edit, and a member
|
||||
// of the group holds what the editor stored.
|
||||
legacy := "legacy." + stamp
|
||||
if err := env.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, `{"`+legacy+`":1}`, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
save(http.StatusOK, map[string]int{write: 1})
|
||||
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{legacy: 1, write: 1}) {
|
||||
t.Fatalf("permissions after an edit over a legacy code = %v", got)
|
||||
}
|
||||
|
||||
// The screen cannot write a membership or the count.
|
||||
created := &models.UserGroup{ID: id}
|
||||
env.join(t, alice, created)
|
||||
update(http.StatusOK, map[string]any{"name": base["name"], "code": code, "users_count": 99, "users": []uint{bob.ID}, "Users": []uint{bob.ID}})
|
||||
if got := env.groupIDs(t, bob.ID); !reflect.DeepEqual(got, sortedIDs(pair)) {
|
||||
t.Fatalf("a group save changed bob's memberships: %v", got)
|
||||
}
|
||||
if rows, _ = list("search=" + stamp); rows[id]["users_count"].(float64) != 1 {
|
||||
t.Fatalf("users_count after a save that sent one = %v", rows[id]["users_count"])
|
||||
}
|
||||
|
||||
// Delete (D-29): the standard form delete removes the group and its
|
||||
// memberships in one transaction; the members and their other
|
||||
// memberships stay.
|
||||
rec = env.expect(t, http.StatusOK, http.MethodDelete, groupPath(pair.ID), token, nil)
|
||||
if env.group(t, pair.ID) != nil {
|
||||
t.Fatalf("the group survived its delete: %s", rec.Body.String())
|
||||
}
|
||||
var left int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users_groups WHERE user_group_id = ?`, pair.ID).Scan(&left).Error; err != nil || left != 0 {
|
||||
t.Fatalf("memberships of the deleted group = %d (%v)", left, err)
|
||||
}
|
||||
if env.reload(t, alice.ID) == nil || env.reload(t, bob.ID) == nil {
|
||||
t.Fatal("a group delete removed a member")
|
||||
}
|
||||
if got := env.groupIDs(t, alice.ID); !reflect.DeepEqual(got, sortedIDs(single, created)) {
|
||||
t.Fatalf("alice's memberships after the delete = %v", got)
|
||||
}
|
||||
if got := env.groupIDs(t, bob.ID); len(got) != 0 {
|
||||
t.Fatalf("bob's memberships after the delete = %v", got)
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, groupPath(pair.ID), token, nil)
|
||||
if rows, total = list("search=" + stamp); total != 3 || rows[pair.ID] != nil {
|
||||
t.Fatalf("list after the delete: total %d", total)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
Permissions: []string{controllers.PermissionAccessUsers},
|
||||
Controller: "golem15.user.users",
|
||||
},
|
||||
{
|
||||
Code: "usergroups",
|
||||
Label: "golem15.user::lang.groups.all_groups",
|
||||
Icon: "users",
|
||||
Permissions: []string{controllers.PermissionAccessGroups},
|
||||
Controller: "golem15.user.usergroups",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -421,4 +421,136 @@ func TestAdminPrivilegedGroups(t *testing.T) {
|
||||
t.Fatalf("group %s is locked although no code is privileged", strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
}
|
||||
|
||||
// D-06: the group's code carries the privilege, so creating a group with
|
||||
// a privileged code, changing a code to or from one, and deleting a
|
||||
// privileged group need the extra permission on the User Groups screen.
|
||||
// The harness database is shared and may hold several groups coded
|
||||
// admin, so the matrix runs on two codes of its own next to admin.
|
||||
priv, second := "priv-"+stamp, "priv2-"+stamp
|
||||
coded := newAdminEnv(t, func(cfg *compass.Config) {
|
||||
if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", priv, second}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
groupsOnly := coded.admin(t, permAccessGroups)
|
||||
groupsManager := coded.admin(t, permAccessGroups, permManagePrivileged)
|
||||
privGroup := coded.seedGroup(t, "Privileged "+stamp, priv)
|
||||
plainCode := "plain-" + stamp
|
||||
plainGroup := coded.seedGroup(t, "Plain "+stamp, plainCode)
|
||||
holder := coded.seedUser(t, "holder", nil)
|
||||
coded.join(t, holder, privGroup, plainGroup)
|
||||
holds := func(code string) bool {
|
||||
t.Helper()
|
||||
held, err := classes.HasGroupCode(t.Context(), coded.gdb, holder.ID, code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return held
|
||||
}
|
||||
refusedCode := func(rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
answer := adminDecode(t, rec)
|
||||
if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupCodeForbidden {
|
||||
t.Fatalf("refusal = %s", rec.Body.String())
|
||||
}
|
||||
if got := answer.Error.Details["code"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupCodeForbidden {
|
||||
t.Fatalf("refusal details = %v", answer.Error.Details)
|
||||
}
|
||||
}
|
||||
sameGroup := func(id uint, name, code string) {
|
||||
t.Helper()
|
||||
got := coded.group(t, id)
|
||||
if got == nil || got.Name != name || got.Code == nil || *got.Code != code {
|
||||
t.Fatalf("group %d = %+v, want %s (%s)", id, got, name, code)
|
||||
}
|
||||
}
|
||||
codeCount := func(code string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := coded.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE code = ?`, code).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
groupPut := func(token string, id uint, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return coded.call(t, http.MethodPut, groupPath(id), token, body)
|
||||
}
|
||||
|
||||
// Without the permission each of the four is refused and changes nothing.
|
||||
refusedCode(coded.call(t, http.MethodPost, adminAPI(adminGroupsPath), groupsOnly, map[string]any{"name": "Second " + stamp, "code": second}))
|
||||
if codeCount(second) != 0 {
|
||||
t.Fatal("a refused create left a privileged group")
|
||||
}
|
||||
refusedCode(groupPut(groupsOnly, plainGroup.ID, map[string]any{"name": "Promoted " + stamp, "code": second}))
|
||||
sameGroup(plainGroup.ID, "Plain "+stamp, plainCode)
|
||||
refusedCode(groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Demoted " + stamp, "code": "demoted-" + stamp}))
|
||||
sameGroup(privGroup.ID, "Privileged "+stamp, priv)
|
||||
// From one privileged code to another is a change of a privileged code.
|
||||
refusedCode(groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Privileged " + stamp, "code": second}))
|
||||
sameGroup(privGroup.ID, "Privileged "+stamp, priv)
|
||||
refusedCode(coded.call(t, http.MethodDelete, groupPath(privGroup.ID), groupsOnly, nil))
|
||||
sameGroup(privGroup.ID, "Privileged "+stamp, priv)
|
||||
pivot(coded, holder, privGroup, plainGroup)
|
||||
if !holds(priv) || holds(second) {
|
||||
t.Fatal("a refused group change moved the privilege")
|
||||
}
|
||||
// The default code admin is protected the same way, and a group without
|
||||
// a code cannot be given a privileged one.
|
||||
refusedCode(groupPut(groupsOnly, adminGroup.ID, map[string]any{"name": "Admins " + stamp, "code": "was-admin-" + stamp}))
|
||||
refusedCode(coded.call(t, http.MethodDelete, groupPath(adminGroup.ID), groupsOnly, nil))
|
||||
sameGroup(adminGroup.ID, "Admins "+stamp, "admin")
|
||||
refusedCode(groupPut(groupsOnly, uncoded.ID, map[string]any{"name": "Uncoded " + stamp, "code": second}))
|
||||
if got := coded.group(t, uncoded.ID); got == nil || got.Code != nil {
|
||||
t.Fatalf("the uncoded group after a refused save = %+v", got)
|
||||
}
|
||||
|
||||
// Everything else on a privileged group needs only the groups
|
||||
// permission: its name, description and frontend permissions, with the
|
||||
// code sent back as it is.
|
||||
if rec := groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Privileged renamed " + stamp, "code": priv, "description": "Kept code", "permissions": map[string]int{}}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("name-only update of a privileged group = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
sameGroup(privGroup.ID, "Privileged renamed "+stamp, priv)
|
||||
// Ordinary groups, a case variant of a privileged code included, are
|
||||
// re-coded and deleted freely.
|
||||
if rec := groupPut(groupsOnly, plainGroup.ID, map[string]any{"name": "Plain " + stamp, "code": "plain2-" + stamp}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("ordinary re-code = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
plainCode = "plain2-" + stamp
|
||||
if rec := groupPut(groupsOnly, uncoded.ID, map[string]any{"name": "Uncoded " + stamp, "code": "coded-" + stamp}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first code of an uncoded group = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := coded.call(t, http.MethodDelete, groupPath(upper.ID), groupsOnly, nil); rec.Code != http.StatusOK || coded.group(t, upper.ID) != nil {
|
||||
t.Fatalf("delete of the ADMIN group = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// With the permission all four succeed.
|
||||
created := coded.call(t, http.MethodPost, adminAPI(adminGroupsPath), groupsManager, map[string]any{"name": "Second " + stamp, "code": second})
|
||||
if created.Code != http.StatusCreated || codeCount(second) != 1 {
|
||||
t.Fatalf("manager create of a privileged group = %d %s", created.Code, created.Body.String())
|
||||
}
|
||||
if rec := groupPut(groupsManager, privGroup.ID, map[string]any{"name": "Privileged renamed " + stamp, "code": "freed-" + stamp}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("manager re-code from a privileged code = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if holds(priv) {
|
||||
t.Fatal("the holder kept the privilege after the group lost its code")
|
||||
}
|
||||
if rec := groupPut(groupsManager, plainGroup.ID, map[string]any{"name": "Plain " + stamp, "code": priv}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("manager re-code to a privileged code = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !holds(priv) {
|
||||
t.Fatal("the holder did not gain the privilege with the group's new code")
|
||||
}
|
||||
if rec := coded.call(t, http.MethodDelete, groupPath(plainGroup.ID), groupsManager, nil); rec.Code != http.StatusOK || coded.group(t, plainGroup.ID) != nil {
|
||||
t.Fatalf("manager delete of a privileged group = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
pivot(coded, holder, privGroup)
|
||||
if holds(priv) || coded.reload(t, holder.ID) == nil {
|
||||
t.Fatal("the delete of the privileged group left the privilege or removed the member")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -78,6 +78,22 @@ user:
|
||||
permissions_tab: Permissions
|
||||
group:
|
||||
label: Group
|
||||
id: ID
|
||||
name: Name
|
||||
code: Code
|
||||
code_comment: Enter a unique code used to identify this group.
|
||||
code_invalid: The code may only contain letters, numbers, dashes and underscores.
|
||||
description_field: Description
|
||||
created_at: Created
|
||||
users_count: Users
|
||||
groups:
|
||||
all_groups: User Groups
|
||||
menu_label: Groups
|
||||
list_title: Manage Groups
|
||||
new_group: New Group
|
||||
list_empty: No user groups yet. Create the first one with New Group.
|
||||
delete_confirm: Do you really want to delete this group?
|
||||
privileged_forbidden: You do not have permission to create, re-code or delete a privileged group. Nothing was saved.
|
||||
organisation:
|
||||
label: Organisation
|
||||
empty_organisation: — No organisation —
|
||||
|
||||
@@ -78,6 +78,22 @@ user:
|
||||
permissions_tab: Uprawnienia
|
||||
group:
|
||||
label: Grupa
|
||||
id: ID
|
||||
name: Nazwa
|
||||
code: Kod
|
||||
code_comment: Wpisz unikalny kod służący do identyfikacji tej grupy.
|
||||
code_invalid: Kod może zawierać tylko litery, cyfry, myślniki i podkreślenia.
|
||||
description_field: Opis
|
||||
created_at: Utworzona
|
||||
users_count: Użytkownicy
|
||||
groups:
|
||||
all_groups: Grupy użytkowników
|
||||
menu_label: Grupy
|
||||
list_title: Zarządzaj grupami
|
||||
new_group: Nowa grupa
|
||||
list_empty: Brak grup użytkowników. Utwórz pierwszą przyciskiem Nowa grupa.
|
||||
delete_confirm: Czy na pewno chcesz usunąć tę grupę?
|
||||
privileged_forbidden: Nie masz uprawnień do tworzenia, zmiany kodu ani usuwania grup uprzywilejowanych. Nic nie zostało zapisane.
|
||||
organisation:
|
||||
label: Organizacja
|
||||
empty_organisation: — Brak organizacji —
|
||||
|
||||
@@ -92,6 +92,18 @@ func permissionInt(value any) (int, bool) {
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Text returns the set as the text of a permission column that is a plain
|
||||
// nullable string on its model (user_groups.permissions): the JSON object
|
||||
// Value writes, or nil for a set without a non-zero value.
|
||||
func (p PermissionSet) Text() *string {
|
||||
value, err := p.Value()
|
||||
text, ok := value.(string)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
return &text
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer: NULL for a set without a non-zero value,
|
||||
// else a JSON object of integers with the zero values left out.
|
||||
func (p PermissionSet) Value() (driver.Value, error) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import "time"
|
||||
|
||||
// UserGroup is a row of user_groups, the PHP UserGroup model. Membership
|
||||
// lives in the users_groups pivot (User.Groups). Permissions is the raw
|
||||
// jsonable text column.
|
||||
// jsonable text column; ParsePermissionSet reads it and PermissionSet.Text
|
||||
// writes it.
|
||||
type UserGroup struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
@@ -13,6 +14,11 @@ type UserGroup struct {
|
||||
Permissions *string `gorm:"column:permissions"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
// UsersCount is the number of members, the PHP users_count relation. It
|
||||
// is not a column of user_groups: only a query that selects an expression
|
||||
// named users_count fills it (the admin list does), it is never written,
|
||||
// and it is zero everywhere else.
|
||||
UsersCount int64 `gorm:"column:users_count;->" json:"-"`
|
||||
}
|
||||
|
||||
func (UserGroup) TableName() string { return "user_groups" }
|
||||
@@ -24,6 +30,21 @@ const (
|
||||
GroupRegistered = "registered"
|
||||
)
|
||||
|
||||
// Fillable is the field list an admin save may set. Permissions is written
|
||||
// through the permission editor, not by a fill.
|
||||
func (UserGroup) Fillable() []string {
|
||||
return []string{"name", "code", "description"}
|
||||
}
|
||||
|
||||
// Rules is the PHP UserGroup rule set without its regex on code, which the
|
||||
// rule engine does not know: the admin controller checks the code's format.
|
||||
func (UserGroup) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|between:3,64",
|
||||
"code": "required|unique:user_groups",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(UserGroup{})
|
||||
}
|
||||
|
||||
15
models/usergroup/columns.yaml
Normal file
15
models/usergroup/columns.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
columns:
|
||||
id:
|
||||
label: golem15.user::lang.group.id
|
||||
invisible: true
|
||||
name:
|
||||
label: golem15.user::lang.group.name
|
||||
searchable: true
|
||||
code:
|
||||
label: golem15.user::lang.group.code
|
||||
created_at:
|
||||
label: golem15.user::lang.group.created_at
|
||||
type: datetime
|
||||
users_count:
|
||||
label: golem15.user::lang.group.users_count
|
||||
sortable: false
|
||||
21
models/usergroup/fields.yaml
Normal file
21
models/usergroup/fields.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
fields:
|
||||
name:
|
||||
label: golem15.user::lang.group.name
|
||||
type: text
|
||||
span: left
|
||||
required: true
|
||||
code:
|
||||
label: golem15.user::lang.group.code
|
||||
comment: golem15.user::lang.group.code_comment
|
||||
type: text
|
||||
span: right
|
||||
preset: name
|
||||
description:
|
||||
label: golem15.user::lang.group.description_field
|
||||
type: textarea
|
||||
size: tiny
|
||||
permissions:
|
||||
type: permissioneditor
|
||||
mode: checkbox
|
||||
span: full
|
||||
tab: golem15.user::lang.user.permissions_tab
|
||||
Reference in New Issue
Block a user