feat: manage a user's groups from the user form, privileged groups locked

- groups relation field on the user form, the only writer of users_groups
- UsersGroup pivot model
- AdminRelationLocks: adding or removing a group whose code is in
  golem15.user.privileged_groups needs golem15.users.manage_privileged_groups,
  on create and on update; refused saves answer 403 and write nothing
- TestAdminUserGroupsField and TestAdminPrivilegedGroups
This commit is contained in:
Jakub Zych
2026-10-05 13:44:15 +02:00
parent 5805c6347f
commit 9611708792
8 changed files with 504 additions and 6 deletions

424
admin_privileged_test.go Normal file
View File

@@ -0,0 +1,424 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
const privilegedGroupForbidden = "You do not have permission to change membership of privileged groups. Nothing was saved."
// groupOptions returns the choices of the user form's groups field that
// match search, as id to locked.
func (e *adminEnv) groupOptions(t *testing.T, token, search string) map[uint]bool {
t.Helper()
var options []struct {
Value uint `json:"value"`
Locked bool `json:"locked"`
}
path := adminAPI(adminUsersPath + "/fields/groups/options?per_page=100&search=" + search)
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &options)
out := map[uint]bool{}
for _, option := range options {
out[option.Value] = option.Locked
}
return out
}
// recordGroups reads a user through the admin API and returns the ids of
// the groups field and, per id, whether its label is flagged locked.
func (e *adminEnv) recordGroups(t *testing.T, token string, userID uint) ([]uint, map[uint]bool) {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(userID, ""), token, nil)
var data struct {
Groups []uint `json:"groups"`
}
answer := adminInto(t, rec, &data)
var meta struct {
Labels map[string][]struct {
Value uint `json:"value"`
Locked bool `json:"locked"`
} `json:"labels"`
}
if err := json.Unmarshal(answer.Meta, &meta); err != nil {
t.Fatalf("record meta %q: %v", string(answer.Meta), err)
}
locked := map[uint]bool{}
for _, label := range meta.Labels["groups"] {
locked[label.Value] = label.Locked
}
return data.Groups, locked
}
// sortedIDs returns the ids of the groups in ascending order, the order
// groupIDs reads the pivot in.
func sortedIDs(groups ...*models.UserGroup) []uint {
ids := make([]uint, 0, len(groups))
for _, group := range groups {
ids = append(ids, group.ID)
}
for i := 1; i < len(ids); i++ {
for j := i; j > 0 && ids[j-1] > ids[j]; j-- {
ids[j-1], ids[j] = ids[j], ids[j-1]
}
}
return ids
}
// TestAdminUserGroupsField covers the groups field of the user form with
// ordinary groups: it is the writer of users_groups, it replaces the user's
// memberships only when the field is sent, it refuses ids that are not
// groups, and none of it reaches a user API payload (D-08).
func TestAdminUserGroupsField(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stamp := adminStamp()
first := env.seedGroup(t, "First "+stamp, "first-"+stamp)
second := env.seedGroup(t, "Second "+stamp, "second-"+stamp)
user := env.seedUser(t, "groups", nil)
base := map[string]any{"name": "groups", "email": user.Email}
save := func(status int, groups any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{"groups": groups}
for key, value := range base {
body[key] = value
}
return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body)
}
pivot := func(want ...*models.UserGroup) {
t.Helper()
if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) {
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...))
}
}
// The field is a multiple relation field on the Account tab, and its
// options are the user groups; none is locked for ordinary codes.
var form struct {
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
Multiple bool `json:"multiple"`
Tab string `json:"tab"`
} `json:"fields"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
found := false
for _, field := range form.Fields {
if field.Name != "groups" {
continue
}
found = true
if field.Type != "relation" || !field.Multiple || field.Tab != "Account" {
t.Fatalf("groups field = %+v", field)
}
}
if !found {
t.Fatal("the user form has no groups field")
}
if options := env.groupOptions(t, token, stamp); len(options) != 2 || options[first.ID] || options[second.ID] {
t.Fatalf("group options = %v", options)
}
// Setting the field writes exactly the chosen memberships.
saved := save(http.StatusOK, []uint{first.ID, second.ID})
pivot(first, second)
var record struct {
Groups []uint `json:"groups"`
}
adminInto(t, saved, &record)
if !reflect.DeepEqual(record.Groups, sortedIDs(first, second)) {
t.Fatalf("record groups = %v", record.Groups)
}
if ids, locked := env.recordGroups(t, token, user.ID); !reflect.DeepEqual(ids, sortedIDs(first, second)) || locked[first.ID] || locked[second.ID] || len(locked) != 2 {
t.Fatalf("shown groups = %v locked %v", ids, locked)
}
save(http.StatusOK, []uint{second.ID})
pivot(second)
// A save without the field leaves the memberships alone.
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "renamed", "email": user.Email})
pivot(second)
if got := env.reload(t, user.ID); got.Name == nil || *got.Name != "renamed" {
t.Fatalf("name after a save without groups = %v", got.Name)
}
// An id that is not a group, a duplicate and a value that is not a list
// are refused on the field and change nothing.
for _, bad := range []any{[]uint{second.ID, 987654321}, []uint{first.ID, first.ID}, first.ID, []string{"x"}} {
rec := save(http.StatusUnprocessableEntity, bad)
if details := adminDecode(t, rec).Error.Details; len(details["groups"]) == 0 {
t.Fatalf("refusal of %v has no detail on groups: %s", bad, rec.Body.String())
}
pivot(second)
}
// The plain relation name of the model is not a way around the field.
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{
"name": "renamed", "email": user.Email, "Groups": []map[string]any{{"id": first.ID}}, "users_groups": []uint{first.ID},
})
pivot(second)
// The user API payload still answers an empty list (D-08), although the
// user is a member of a group.
status, userToken := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK {
t.Fatalf("user login = %d", status)
}
fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil)
var body struct {
User map[string]any `json:"user"`
}
if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil || fetch.Code != http.StatusOK {
t.Fatalf("fetch = %d %s (%v)", fetch.Code, fetch.Body.String(), err)
}
if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 {
t.Fatalf("user payload groups = %v", body.User["groups"])
}
// Clearing the field removes every membership.
save(http.StatusOK, []uint{})
pivot()
// A create with the field writes the memberships of the new user.
const password = "groups-password-1"
created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
"name": "Created", "email": "groups-created-" + stamp + "@example.test",
"password": password, "password_confirmation": password, "send_invite": false,
"groups": []uint{first.ID},
})
if got := env.groupIDs(t, createdID(t, created)); !reflect.DeepEqual(got, []uint{first.ID}) {
t.Fatalf("users_groups of the created user = %v", got)
}
// A refused create leaves no user behind.
refusedEmail := "groups-refused-" + stamp + "@example.test"
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
"name": "Refused", "email": refusedEmail, "password": password, "password_confirmation": password, "send_invite": false,
"groups": []uint{987654321},
})
var refused int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&refused).Error; err != nil || refused != 0 {
t.Fatalf("a refused create left %d users (%v)", refused, err)
}
}
// TestAdminPrivilegedGroups covers threat T-12-18 as revisited by D-04 to
// D-07: golem15.users.access_users changes ordinary memberships, and adding
// or removing a privileged group additionally needs
// golem15.users.manage_privileged_groups, on create and on update. It also
// ties the field to the takeover guard (D-30).
func TestAdminPrivilegedGroups(t *testing.T) {
env := newAdminEnv(t)
limited := env.admin(t, permAccessUsers)
manager := env.admin(t, permAccessUsers, permManagePrivileged)
stamp := adminStamp()
// The migrations seed only guest and registered; the default privileged
// list is [admin].
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
ordinaryCode := "ordinary-" + stamp
ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode)
uncoded := env.seedGroup(t, "Uncoded "+stamp, "")
// A code that differs from a privileged one only by case is ordinary.
upper := env.seedGroup(t, "Upper "+stamp, "ADMIN")
isAdmin := func(e *adminEnv, userID uint) bool {
t.Helper()
held, err := classes.HasGroupCode(t.Context(), e.gdb, userID, "admin")
if err != nil {
t.Fatal(err)
}
return held
}
put := func(e *adminEnv, status int, token string, user *models.User, extra map[string]any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{"name": "kept", "email": user.Email}
for key, value := range extra {
body[key] = value
}
return e.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body)
}
assertRefused := func(rec *httptest.ResponseRecorder) {
t.Helper()
answer := adminDecode(t, rec)
if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupForbidden {
t.Fatalf("refusal = %s", rec.Body.String())
}
if got := answer.Error.Details["groups"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupForbidden {
t.Fatalf("refusal details = %v", answer.Error.Details)
}
}
pivot := func(e *adminEnv, user *models.User, want ...*models.UserGroup) {
t.Helper()
if got := e.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) {
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...))
}
}
unchanged := func(original *models.User) {
t.Helper()
got := env.reload(t, original.ID)
if got == nil || got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) ||
got.Password != original.Password || !reflect.DeepEqual(got.OrganisationID, original.OrganisationID) {
t.Fatalf("user %d changed: %+v", original.ID, got)
}
}
// Without the permission the privileged group is flagged locked among the
// options; a group without a code and one whose code differs by case are
// not. With the permission nothing is locked.
if options := env.groupOptions(t, limited, stamp); len(options) != 4 || !options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] {
t.Fatalf("options without the permission = %v", options)
}
if options := env.groupOptions(t, manager, stamp); len(options) != 4 || options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] {
t.Fatalf("options with the permission = %v", options)
}
// Adding the privileged group on update is refused as a whole: the name
// in the same body is not saved and the pivot is unchanged.
target := env.seedUser(t, "target", nil)
env.join(t, target, ordinary)
if isAdmin(env, target.ID) {
t.Fatal("the target starts as a site admin")
}
assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"name": "Promoted", "groups": []uint{ordinary.ID, adminGroup.ID}}))
pivot(env, target, ordinary)
unchanged(target)
if isAdmin(env, target.ID) {
t.Fatal("a refused save made a site admin")
}
// The privileged group alone, replacing the ordinary one, is refused too.
assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"groups": []uint{adminGroup.ID}}))
pivot(env, target, ordinary)
// Removing it from a member is refused: by leaving it out, and by
// clearing the field.
member := env.seedUser(t, "member", nil)
env.join(t, member, adminGroup, ordinary)
for _, groups := range [][]uint{{ordinary.ID}, {}} {
assertRefused(put(env, http.StatusForbidden, limited, member, map[string]any{"name": "Demoted", "groups": groups}))
pivot(env, member, adminGroup, ordinary)
unchanged(member)
if !isAdmin(env, member.ID) {
t.Fatal("a refused save removed a site admin")
}
}
// The member's privileged label is flagged locked for the limited admin
// and not for the manager.
if ids, locked := env.recordGroups(t, limited, member.ID); !reflect.DeepEqual(ids, sortedIDs(adminGroup, ordinary)) || !locked[adminGroup.ID] || locked[ordinary.ID] {
t.Fatalf("member groups for the limited admin = %v locked %v", ids, locked)
}
if _, locked := env.recordGroups(t, manager, member.ID); locked[adminGroup.ID] || locked[ordinary.ID] {
t.Fatalf("member groups for the manager are locked: %v", locked)
}
// Creating a user who is already in the privileged group is refused and
// leaves no user row.
const password = "privileged-password-1"
create := func(token, email string, groups []uint) *httptest.ResponseRecorder {
t.Helper()
return env.call(t, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
"name": "Created", "email": email, "password": password, "password_confirmation": password, "send_invite": false,
"groups": groups,
})
}
refusedEmail := "born-admin-" + stamp + "@example.test"
rec := create(limited, refusedEmail, []uint{adminGroup.ID, ordinary.ID})
if rec.Code != http.StatusForbidden {
t.Fatalf("create with a privileged group = %d %s", rec.Code, rec.Body.String())
}
assertRefused(rec)
var born int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&born).Error; err != nil || born != 0 {
t.Fatalf("a refused create left %d users (%v)", born, err)
}
// Ordinary changes need only the users permission, also on a member of
// the privileged group as long as that membership is sent back as it is;
// a group without a code and a case variant are ordinary.
put(env, http.StatusOK, limited, member, map[string]any{"groups": []uint{adminGroup.ID, uncoded.ID, upper.ID}})
pivot(env, member, adminGroup, uncoded, upper)
put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{uncoded.ID}})
pivot(env, target, uncoded)
put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{}})
pivot(env, target)
if isAdmin(env, target.ID) || !isAdmin(env, member.ID) {
t.Fatal("ordinary changes moved the site-admin membership")
}
// With the permission every refused request succeeds.
put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID, adminGroup.ID}})
pivot(env, target, ordinary, adminGroup)
if !isAdmin(env, target.ID) {
t.Fatal("the manager could not add the privileged group")
}
put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID}})
pivot(env, target, ordinary)
put(env, http.StatusOK, manager, member, map[string]any{"groups": []uint{}})
pivot(env, member)
if isAdmin(env, target.ID) || isAdmin(env, member.ID) {
t.Fatal("the manager could not remove the privileged group")
}
rec = create(manager, "born-admin-ok-"+stamp+"@example.test", []uint{adminGroup.ID})
if rec.Code != http.StatusCreated {
t.Fatalf("manager create with a privileged group = %d %s", rec.Code, rec.Body.String())
}
if bornID := createdID(t, rec); !isAdmin(env, bornID) {
t.Fatal("the manager's create did not write the privileged membership")
}
// D-30: the takeover guard follows the membership this field writes. No
// row is inserted into users_groups directly here.
guarded := env.seedUser(t, "guarded", nil)
newEmail := "guarded-new-" + stamp + "@example.test"
put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{adminGroup.ID}})
rec = env.expect(t, http.StatusForbidden, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail})
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 {
t.Fatalf("takeover refusal = %s", rec.Body.String())
}
if got := env.reload(t, guarded.ID); got.Email != guarded.Email {
t.Fatalf("a refused takeover changed the email to %q", got.Email)
}
put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{}})
env.expect(t, http.StatusOK, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail})
if got := env.reload(t, guarded.ID); got.Email != newEmail {
t.Fatalf("email after the membership was removed = %q", got.Email)
}
// The privileged list is config, read per request. With the ordinary
// group's code in it, that group is the locked one and admin is not.
moved := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil {
t.Fatal(err)
}
})
movedLimited := moved.admin(t, permAccessUsers)
if options := moved.groupOptions(t, movedLimited, stamp); len(options) != 4 || options[adminGroup.ID] || !options[ordinary.ID] || options[uncoded.ID] {
t.Fatalf("options under the moved list = %v", options)
}
put(moved, http.StatusOK, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID}})
pivot(moved, member, adminGroup)
assertRefused(put(moved, http.StatusForbidden, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID, ordinary.ID}}))
pivot(moved, member, adminGroup)
// target is in the ordinary group, which is now the privileged one.
assertRefused(put(moved, http.StatusForbidden, movedLimited, target, map[string]any{"groups": []uint{}}))
pivot(moved, target, ordinary)
// An empty list means no group is privileged: nothing is locked.
open := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{}); err != nil {
t.Fatal(err)
}
})
openLimited := open.admin(t, permAccessUsers)
for id, locked := range open.groupOptions(t, openLimited, stamp) {
if locked {
t.Fatalf("group %s is locked although no code is privileged", strconv.FormatUint(uint64(id), 10))
}
}
}

View File

@@ -1418,9 +1418,9 @@ func TestAdminUserFormFields(t *testing.T) {
}; !reflect.DeepEqual(offered, want) {
t.Fatalf("permission options = %v, want %v", offered, want)
}
// The form has no login-name, block-mail, guest or groups field (D-03,
// D-18, D-21; groups arrive with their guard).
if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) {
// The form has no login-name, block-mail or guest field (D-03, D-18,
// D-21).
if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "groups", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) {
t.Fatalf("form fields = %v, want %v", names, want)
}

View File

@@ -34,5 +34,6 @@ func init() {
var _ pact.FormBeforeCreate = usersAdminController{}
var _ pact.FormAfterCreate = usersAdminController{}
var _ cabana.FieldRelationProvider = usersAdminController{}
var _ cabana.RelationLockProvider = usersAdminController{}
var _ cabana.PermissionEditorProvider = usersAdminController{}
}

View File

@@ -336,9 +336,15 @@ func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) er
return nil
}
// AdminFieldRelations binds the organisation picker. organisation_id is a
// protected column, so the contract opts in to writing it through this one
// field; the submitted id is still checked against the options.
// groupsField is the form's group membership field.
const groupsField = "groups"
// AdminFieldRelations binds the organisation picker and the groups field.
// organisation_id is a protected column, so the contract opts in to writing
// it through this one field; the submitted id is still checked against the
// options. groups is the users_groups pivot: a save that carries the field
// replaces the user's memberships, and AdminRelationLocks decides which of
// them the administrator may change.
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{
{
@@ -349,9 +355,47 @@ func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract
LabelColumn: "name",
WritableForeignKey: true,
},
{
Field: groupsField,
Kind: "belongsToMany",
NewRelated: func() any { return &models.UserGroup{} },
NewPivot: func() any { return &models.UsersGroup{} },
ParentForeignKey: "user_id",
RelatedForeignKey: "user_group_id",
LabelColumn: "name",
},
}
}
// AdminRelationLocks locks the privileged groups in the groups field for an
// administrator who lacks golem15.users.manage_privileged_groups: the
// framework shows them locked and answers 403, before any row is written, to
// a create or update that would add or remove one. Membership of a
// privileged group makes a site administrator, so golem15.users.access_users
// alone must not be able to grant or revoke it. The privileged codes come
// from golem15.user.privileged_groups on every call and the group ids are
// read through the write's transaction when there is one. An administrator
// without a principal on the context gets the locks. Every other field has
// none.
func (c usersAdminController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
if field != groupsField {
return cabana.RelationLock{}, nil
}
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
return cabana.RelationLock{}, nil
}
db := requestDB(ctx, c.db)
if db == nil {
return cabana.RelationLock{}, errors.New("users admin: gorm handle is missing")
}
ids, err := classes.PrivilegedGroupIDs(ctx, db, classes.PrivilegedGroupCodes(c.config()))
if err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids, Message: usersLang + "privileged_group_forbidden"}, nil
}
// permissionsField is the form's permission editor field.
const permissionsField = "permissions"

View File

@@ -53,6 +53,7 @@ users:
unsuspend_success: User has been unsuspended.
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
privileged_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved.
user:
label: User
id: ID
@@ -72,6 +73,8 @@ user:
confirm_password: Password Confirmation
confirm_password_comment: Enter the password again to confirm it.
avatar: Avatar
groups: Groups
empty_groups: There are no user groups available.
permissions_tab: Permissions
group:
label: Group

View File

@@ -53,6 +53,7 @@ users:
unsuspend_success: Udało się odblokować użytkownika.
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
privileged_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.
user:
label: Użytkownik
id: ID
@@ -72,6 +73,8 @@ user:
confirm_password: Potwierdzenie hasła
confirm_password_comment: Wprowadź ponownie hasło, aby je potwierdzić.
avatar: Avatar
groups: Grupy
empty_groups: Brak dostępnych grup użytkowników.
permissions_tab: Uprawnienia
group:
label: Grupa

View File

@@ -35,6 +35,12 @@ fields:
span: right
tab: golem15.user::lang.user.account
context: [create, update]
groups:
label: golem15.user::lang.user.groups
type: relation
span: full
emptyOption: golem15.user::lang.user.empty_groups
tab: golem15.user::lang.user.account
organisation:
label: golem15.user::lang.organisation.label
type: relation

17
models/users_group.go Normal file
View File

@@ -0,0 +1,17 @@
package models
// UsersGroup is one row of users_groups, the pivot behind User.Groups: the
// user is a member of the group. The table has exactly these two columns and
// a composite primary key over them. The admin user form's groups field is
// its only writer, apart from the cleanup of a permanently deleted user or
// group.
type UsersGroup struct {
UserID uint `gorm:"column:user_id;primaryKey"`
UserGroupID uint `gorm:"column:user_group_id;primaryKey"`
}
func (UsersGroup) TableName() string { return "users_groups" }
func init() {
Register(UsersGroup{})
}