feat: Organisations admin screen with the avatar and the members manager
- controller golem15.user.organisations behind golem15.users.access_users - list and form (name, slug preset from the name, description, avatar) - members relation manager: link sets and unlink clears users.organisation_id - deleting an organisation releases its members first - Organisation gains Fillable, Rules, MorphName, AttachRelations, BeforeValidate and the Members relation; models.Slugify - navigation side item organisations - TestAdminOrganisations, TestAdminOrganisationMembers, TestSlugify
This commit is contained in:
1
admin.go
1
admin.go
@@ -14,6 +14,7 @@ import (
|
||||
//
|
||||
//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
|
||||
//go:embed controllers/organisations/config_list.yaml controllers/organisations/config_form.yaml controllers/organisations/config_relation.yaml models/organisation/fields.yaml models/organisation/columns.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestAdminGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
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"}) {
|
||||
if got := env.sideMenu(t, usersOnly); !reflect.DeepEqual(got, []string{"users=golem15.user.users", "organisations=golem15.user.organisations"}) {
|
||||
t.Fatalf("side menu with the users permission = %v", got)
|
||||
}
|
||||
if got := env.sideMenu(t, token); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
|
||||
|
||||
@@ -32,6 +32,13 @@ func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
Permissions: []string{controllers.PermissionAccessGroups},
|
||||
Controller: "golem15.user.usergroups",
|
||||
},
|
||||
{
|
||||
Code: "organisations",
|
||||
Label: "golem15.user::lang.organisation.menu_label",
|
||||
Icon: "users-round",
|
||||
Permissions: []string{controllers.PermissionAccessUsers},
|
||||
Controller: "golem15.user.organisations",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
482
admin_organisations_test.go
Normal file
482
admin_organisations_test.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// adminOrganisationsPath is the Organisations controller under the admin API.
|
||||
const adminOrganisationsPath = "/golem15/user/organisations"
|
||||
|
||||
// organisationPath is the admin API path of one organisation, with an
|
||||
// optional suffix such as "/relations/members".
|
||||
func organisationPath(id uint, suffix string) string {
|
||||
return adminAPI(adminOrganisationsPath) + "/" + strconv.FormatUint(uint64(id), 10) + suffix
|
||||
}
|
||||
|
||||
// organisation reads an organisation row; nil when it is gone.
|
||||
func (e *adminEnv) organisation(t *testing.T, id uint) *models.Organisation {
|
||||
t.Helper()
|
||||
var rows []models.Organisation
|
||||
if err := e.gdb.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &rows[0]
|
||||
}
|
||||
|
||||
// seedOrganisation stores an organisation with a unique slug.
|
||||
func (e *adminEnv) seedOrganisation(t *testing.T, name string) *models.Organisation {
|
||||
t.Helper()
|
||||
organisation := &models.Organisation{Name: name, Slug: models.Slugify(name)}
|
||||
if err := e.gdb.Create(organisation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return organisation
|
||||
}
|
||||
|
||||
// TestSlugify pins the slug rule the organisation model shares with the
|
||||
// admin form's slug preset.
|
||||
func TestSlugify(t *testing.T) {
|
||||
for text, want := range map[string]string{
|
||||
"Acme Records": "acme-records",
|
||||
" Acme -- Records ": "acme-records",
|
||||
"ACME_Records 2": "acme-records-2",
|
||||
"Zażółć gęślą": "za-g-l",
|
||||
"---": "",
|
||||
"": "",
|
||||
"a": "a",
|
||||
} {
|
||||
if got := models.Slugify(text); got != want {
|
||||
t.Fatalf("Slugify(%q) = %q, want %q", text, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOrganisations covers the Organisations screen (D-22, D-20): its
|
||||
// permission, the list, the form with the slug rules and the avatar, and the
|
||||
// delete that releases the members.
|
||||
func TestAdminOrganisations(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
groupsOnly := env.admin(t, permAccessGroups)
|
||||
both := env.admin(t, permAccessUsers, permAccessGroups)
|
||||
stamp := adminStamp()
|
||||
|
||||
// The screen needs golem15.users.access_users.
|
||||
for _, path := range []string{adminAPI(adminOrganisationsPath), adminAPI(adminOrganisationsPath + "/schema/list"), adminAPI(adminOrganisationsPath + "/schema/form")} {
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, groupsOnly, 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(adminOrganisationsPath), groupsOnly, map[string]any{"name": "Nope " + stamp})
|
||||
// All three screens are side items of `user`, in this order, each shown
|
||||
// only with its permission.
|
||||
if got := env.sideMenu(t, both); !reflect.DeepEqual(got, []string{
|
||||
"users=golem15.user.users", "usergroups=golem15.user.usergroups", "organisations=golem15.user.organisations",
|
||||
}) {
|
||||
t.Fatalf("side menu with both permissions = %v", got)
|
||||
}
|
||||
if got := env.sideMenu(t, groupsOnly); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
|
||||
t.Fatalf("side menu with the groups permission = %v", got)
|
||||
}
|
||||
|
||||
// The list schema: Name, Slug and Created; rows open the update form.
|
||||
var schema struct {
|
||||
RecordURL string `json:"recordUrl"`
|
||||
ShowCheckboxes bool `json:"showCheckboxes"`
|
||||
ToolbarButtons []string `json:"toolbarButtons"`
|
||||
Columns []struct {
|
||||
Key string `json:"key"`
|
||||
Invisible bool `json:"invisible"`
|
||||
Searchable bool `json:"searchable"`
|
||||
Type string `json:"type"`
|
||||
} `json:"columns"`
|
||||
Messages map[string]map[string]string `json:"messages"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/list"), token, nil), &schema)
|
||||
var visible []string
|
||||
for _, column := range schema.Columns {
|
||||
if !column.Invisible {
|
||||
visible = append(visible, column.Key)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(visible, []string{"name", "slug", "created_at"}) || schema.RecordURL != "golem15/user/organisations/update/:id" ||
|
||||
schema.ShowCheckboxes || !reflect.DeepEqual(schema.ToolbarButtons, []string{"create"}) {
|
||||
t.Fatalf("list schema = %+v", schema)
|
||||
}
|
||||
if got := schema.Messages["empty"]["other"]; got != "No organisations yet. Create the first one with New Organisation." {
|
||||
t.Fatalf("empty message = %q", got)
|
||||
}
|
||||
if got := schema.Messages["create"]["other"]; got != "New Organisation" {
|
||||
t.Fatalf("create message = %q", got)
|
||||
}
|
||||
|
||||
// The form: name, slug (slug preset from name), description, the avatar
|
||||
// as a 120 by 120 image and the members manager on update only; no
|
||||
// preview.
|
||||
var form struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Mode string `json:"mode"`
|
||||
Tab string `json:"tab"`
|
||||
Context any `json:"context"`
|
||||
ImageWidth int `json:"imageWidth"`
|
||||
ImageHeight int `json:"imageHeight"`
|
||||
Preset *struct {
|
||||
Field string `json:"field"`
|
||||
Type string `json:"type"`
|
||||
} `json:"preset"`
|
||||
} `json:"fields"`
|
||||
Preview json.RawMessage `json:"preview"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil), &form)
|
||||
var names []string
|
||||
for _, field := range form.Fields {
|
||||
names = append(names, field.Name+":"+field.Type)
|
||||
switch field.Name {
|
||||
case "slug":
|
||||
if field.Preset == nil || field.Preset.Field != "name" || field.Preset.Type != "slug" {
|
||||
t.Fatalf("slug preset = %+v", field.Preset)
|
||||
}
|
||||
case "avatar":
|
||||
if field.Mode != "image" || field.ImageWidth != 120 || field.ImageHeight != 120 {
|
||||
t.Fatalf("avatar field = %+v", field)
|
||||
}
|
||||
case "members":
|
||||
if field.Context != "update" || field.Tab != "Members" {
|
||||
t.Fatalf("members field = %+v", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "members:relation-manager"}; !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 organisation form has a preview: %s", form.Preview)
|
||||
}
|
||||
|
||||
// Create: an empty slug is derived from the name; a given slug is kept.
|
||||
create := func(status int, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return env.expect(t, status, http.MethodPost, adminAPI(adminOrganisationsPath), token, body)
|
||||
}
|
||||
name := "Acme Records " + stamp
|
||||
id := createdID(t, create(http.StatusCreated, map[string]any{"name": name, "slug": "", "description": "A label"}))
|
||||
if got := env.organisation(t, id); got == nil || got.Name != name || got.Slug != "acme-records-"+stamp || got.Description == nil || *got.Description != "A label" {
|
||||
t.Fatalf("created organisation = %+v", got)
|
||||
}
|
||||
omitted := createdID(t, create(http.StatusCreated, map[string]any{"name": "Omitted Slug " + stamp}))
|
||||
if got := env.organisation(t, omitted); got.Slug != "omitted-slug-"+stamp {
|
||||
t.Fatalf("slug of a create without the key = %q", got.Slug)
|
||||
}
|
||||
given := createdID(t, create(http.StatusCreated, map[string]any{"name": "Given " + stamp, "slug": "Given_Slug-" + stamp}))
|
||||
if got := env.organisation(t, given); got.Slug != "Given_Slug-"+stamp {
|
||||
t.Fatalf("given slug = %q", got.Slug)
|
||||
}
|
||||
// A slug with other characters, a duplicate slug and a missing name are
|
||||
// refused on their field and create nothing.
|
||||
for _, bad := range []string{"has space " + stamp, "dot." + stamp, "ślad-" + stamp} {
|
||||
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Bad " + stamp, "slug": bad})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) != 1 || details["slug"][0] != "The slug may only contain letters, numbers, dashes and underscores." {
|
||||
t.Fatalf("slug refusal for %q = %s", bad, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Duplicate " + stamp, "slug": "acme-records-" + stamp})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
|
||||
t.Fatalf("duplicate slug refusal = %s", rec.Body.String())
|
||||
}
|
||||
// The derived slug collides as well.
|
||||
rec = create(http.StatusUnprocessableEntity, map[string]any{"name": name})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
|
||||
t.Fatalf("derived duplicate slug refusal = %s", rec.Body.String())
|
||||
}
|
||||
rec = create(http.StatusUnprocessableEntity, map[string]any{"slug": "nameless-" + stamp})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["name"]) == 0 {
|
||||
t.Fatalf("missing name refusal = %s", rec.Body.String())
|
||||
}
|
||||
var count int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_organisations WHERE name LIKE ?`, "%"+stamp).Scan(&count).Error; err != nil || count != 3 {
|
||||
t.Fatalf("organisations after the refused creates = %d (%v)", count, err)
|
||||
}
|
||||
|
||||
// The list finds it by name and by slug.
|
||||
for _, term := range []string{"Acme+Records+" + stamp, "acme-records-" + stamp} {
|
||||
var rows []map[string]any
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath)+"?search="+term, token, nil), &rows)
|
||||
if len(rows) != 1 || rows[0]["name"] != name || rows[0]["slug"] != "acme-records-"+stamp {
|
||||
t.Fatalf("list for %q = %v", term, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// Update: the fields change; a bad slug is refused and changes nothing.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "acme-" + stamp, "description": "Renamed slug"})
|
||||
if got := env.organisation(t, id); got.Slug != "acme-"+stamp || *got.Description != "Renamed slug" {
|
||||
t.Fatalf("updated organisation = %+v", got)
|
||||
}
|
||||
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "acme " + stamp})
|
||||
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "Given_Slug-" + stamp})
|
||||
if got := env.organisation(t, id); got.Slug != "acme-"+stamp {
|
||||
t.Fatalf("a refused update changed the slug to %q", got.Slug)
|
||||
}
|
||||
|
||||
// Avatar: a file uploaded through the admin file route is attached to
|
||||
// the organisation under its morph name when the form is saved.
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file_data", "logo.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(jpegBytes(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionKey := adminSessionKey(t)
|
||||
headers := map[string]string{cabana.SessionKeyHeader: sessionKey}
|
||||
if up := env.send(t, http.MethodPost, organisationPath(id, "/files/avatar"), token, buf.Bytes(), mw.FormDataContentType(), headers); up.Code != http.StatusCreated && up.Code != http.StatusOK {
|
||||
t.Fatalf("avatar upload = %d %s", up.Code, up.Body.String())
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"name": name, "slug": "acme-" + stamp})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved := env.send(t, http.MethodPut, organisationPath(id, ""), token, body, "application/json", headers); saved.Code != http.StatusOK {
|
||||
t.Fatalf("save with the avatar = %d %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
files := func() int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`,
|
||||
`Golem15\User\Models\Organisation`, strconv.FormatUint(uint64(id), 10)).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
if files() != 1 || (models.Organisation{}).MorphName() != `Golem15\User\Models\Organisation` {
|
||||
t.Fatalf("avatar rows of the organisation = %d", files())
|
||||
}
|
||||
|
||||
// Delete: an organisation with members goes; its members stay, active or
|
||||
// deactivated, without an organisation and without a role in it. Users
|
||||
// of another organisation are not touched.
|
||||
role := "owner"
|
||||
owner := env.seedUser(t, "org-owner", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &id, &role })
|
||||
gone := env.seedUser(t, "org-gone", func(u *models.User) { u.OrganisationID = &id })
|
||||
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := env.seedUser(t, "org-other", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &given, &role })
|
||||
group := env.seedGroup(t, "Org "+stamp, "org-"+stamp)
|
||||
env.join(t, owner, group)
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(id, ""), token, nil)
|
||||
if env.organisation(t, id) != nil {
|
||||
t.Fatal("the organisation survived its delete")
|
||||
}
|
||||
for _, member := range []*models.User{owner, gone} {
|
||||
got := env.reload(t, member.ID)
|
||||
if got == nil || got.OrganisationID != nil || got.OrganisationRole != nil || got.Email != member.Email {
|
||||
t.Fatalf("member %d after the delete = %+v", member.ID, got)
|
||||
}
|
||||
}
|
||||
if got := env.reload(t, other.ID); got.OrganisationID == nil || *got.OrganisationID != given || got.OrganisationRole == nil {
|
||||
t.Fatalf("a member of another organisation changed: %+v", got)
|
||||
}
|
||||
if got := env.groupIDs(t, owner.ID); !reflect.DeepEqual(got, []uint{group.ID}) {
|
||||
t.Fatalf("the delete changed the owner's groups: %v", got)
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, organisationPath(id, ""), token, nil)
|
||||
}
|
||||
|
||||
// TestAdminOrganisationMembers covers the members relation manager: link
|
||||
// sets and unlink clears users.organisation_id, the lists are scoped to the
|
||||
// organisation, and nothing else of a user changes, least of all
|
||||
// users_groups (T-12.1-30).
|
||||
func TestAdminOrganisationMembers(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
groupsOnly := env.admin(t, permAccessGroups)
|
||||
stamp := adminStamp()
|
||||
here := env.seedOrganisation(t, "Here "+stamp)
|
||||
there := env.seedOrganisation(t, "There "+stamp)
|
||||
label := "Member" + stamp
|
||||
named := func(suffix string, mutate func(*models.User)) *models.User {
|
||||
return env.seedUser(t, "member-"+suffix, func(u *models.User) {
|
||||
name := label + " " + suffix
|
||||
u.Name = &name
|
||||
if mutate != nil {
|
||||
mutate(u)
|
||||
}
|
||||
})
|
||||
}
|
||||
first, second := named("first", nil), named("second", nil)
|
||||
role := "member"
|
||||
foreign := named("foreign", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &there.ID, &role })
|
||||
group := env.seedGroup(t, "Members "+stamp, "members-"+stamp)
|
||||
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
||||
env.join(t, first, group, adminGroup)
|
||||
env.join(t, second, group)
|
||||
env.join(t, foreign, adminGroup)
|
||||
everyone := []*models.User{first, second, foreign}
|
||||
memberships := map[uint][]uint{}
|
||||
stored := map[uint]*models.User{}
|
||||
for _, user := range everyone {
|
||||
memberships[user.ID] = env.groupIDs(t, user.ID)
|
||||
stored[user.ID] = env.reload(t, user.ID)
|
||||
}
|
||||
untouched := func() {
|
||||
t.Helper()
|
||||
for _, user := range everyone {
|
||||
if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, memberships[user.ID]) {
|
||||
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, memberships[user.ID])
|
||||
}
|
||||
got, want := env.reload(t, user.ID), stored[user.ID]
|
||||
if got == nil || got.Email != want.Email || got.Password != want.Password || got.IsActivated != want.IsActivated ||
|
||||
!reflect.DeepEqual(got.Name, want.Name) || !reflect.DeepEqual(got.OrganisationRole, want.OrganisationRole) ||
|
||||
!reflect.DeepEqual(got.TokensValidAfter, want.TokensValidAfter) || got.DeletedAt.Valid {
|
||||
t.Fatalf("user %d changed beyond organisation_id: %+v", user.ID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
organisationOf := func(user *models.User) uint {
|
||||
t.Helper()
|
||||
got := env.reload(t, user.ID)
|
||||
if got == nil || got.OrganisationID == nil {
|
||||
return 0
|
||||
}
|
||||
return *got.OrganisationID
|
||||
}
|
||||
ids := func(path string) []uint {
|
||||
t.Helper()
|
||||
var rows []map[string]any
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &rows)
|
||||
out := []uint{}
|
||||
for _, row := range rows {
|
||||
out = append(out, uint(row["id"].(float64)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
members := func(organisation *models.Organisation) []uint {
|
||||
t.Helper()
|
||||
return ids(organisationPath(organisation.ID, "/relations/members"))
|
||||
}
|
||||
candidates := func() []uint {
|
||||
t.Helper()
|
||||
return ids(organisationPath(here.ID, "/relations/members/candidates?search="+label))
|
||||
}
|
||||
mutate := func(organisation *models.Organisation, action string, users ...*models.User) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
list := make([]uint, len(users))
|
||||
for i, user := range users {
|
||||
list[i] = user.ID
|
||||
}
|
||||
return env.call(t, http.MethodPost, organisationPath(organisation.ID, "/relations/members/"+action), token, map[string]any{"ids": list})
|
||||
}
|
||||
|
||||
// The relation schema carries the screen's copy: the unlink confirm
|
||||
// counts and says the users are not deleted.
|
||||
var relation struct {
|
||||
Messages map[string]map[string]string `json:"messages"`
|
||||
}
|
||||
rawRelation := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/relation/members"), token, nil)
|
||||
adminInto(t, rawRelation, &relation)
|
||||
for key, want := range map[string]string{
|
||||
"link": "Add member",
|
||||
"unlinkSelected": "Remove selected",
|
||||
"unlinkConfirm": "Remove the selected (:count) from this organisation? The users are not deleted.",
|
||||
"empty": "This organisation has no members yet. Add users with Add member.",
|
||||
} {
|
||||
if got := relation.Messages[key]["other"]; got != want {
|
||||
t.Fatalf("relation message %s = %q, want %q (%s)", key, got, want, rawRelation.Body.String())
|
||||
}
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/relation/groups"), token, nil)
|
||||
|
||||
// The routes need the screen's permission.
|
||||
env.expect(t, http.StatusForbidden, http.MethodGet, organisationPath(here.ID, "/relations/members"), groupsOnly, nil)
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, organisationPath(here.ID, "/relations/members/link"), groupsOnly, map[string]any{"ids": []uint{first.ID}})
|
||||
if organisationOf(first) != 0 {
|
||||
t.Fatal("a refused link set organisation_id")
|
||||
}
|
||||
|
||||
// An organisation without members lists none; the candidates are the
|
||||
// users without an organisation.
|
||||
if got := members(here); len(got) != 0 {
|
||||
t.Fatalf("members of a new organisation = %v", got)
|
||||
}
|
||||
if got := candidates(); !reflect.DeepEqual(got, []uint{first.ID, second.ID}) {
|
||||
t.Fatalf("candidates = %v, want %v", got, []uint{first.ID, second.ID})
|
||||
}
|
||||
|
||||
// Link sets organisation_id of both users.
|
||||
if rec := mutate(here, "link", first, second); rec.Code != http.StatusOK {
|
||||
t.Fatalf("link = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if organisationOf(first) != here.ID || organisationOf(second) != here.ID {
|
||||
t.Fatalf("organisation_id after the link = %d, %d", organisationOf(first), organisationOf(second))
|
||||
}
|
||||
untouched()
|
||||
// Each list shows only its own organisation's members.
|
||||
if got := members(here); !reflect.DeepEqual(got, []uint{first.ID, second.ID}) {
|
||||
t.Fatalf("members here = %v", got)
|
||||
}
|
||||
if got := members(there); !reflect.DeepEqual(got, []uint{foreign.ID}) {
|
||||
t.Fatalf("members there = %v", got)
|
||||
}
|
||||
if got := candidates(); len(got) != 0 {
|
||||
t.Fatalf("candidates after the link = %v", got)
|
||||
}
|
||||
|
||||
// A member of another organisation is not a candidate and cannot be
|
||||
// taken over by a link: they stay where they are.
|
||||
refused := mutate(here, "link", foreign)
|
||||
if details := adminDecode(t, refused).Error.Details; refused.Code != http.StatusUnprocessableEntity || len(details["ids"]) == 0 {
|
||||
t.Fatalf("link of a foreign member = %d %s", refused.Code, refused.Body.String())
|
||||
}
|
||||
if organisationOf(foreign) != there.ID {
|
||||
t.Fatalf("a foreign member moved to %d", organisationOf(foreign))
|
||||
}
|
||||
// Unlinking through the wrong organisation is ignored.
|
||||
mutate(here, "unlink", foreign)
|
||||
if organisationOf(foreign) != there.ID {
|
||||
t.Fatal("an unlink through another organisation cleared organisation_id")
|
||||
}
|
||||
untouched()
|
||||
|
||||
// Unlink clears organisation_id of the chosen user only; the user stays.
|
||||
if rec := mutate(here, "unlink", first); rec.Code != http.StatusOK {
|
||||
t.Fatalf("unlink = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if organisationOf(first) != 0 || organisationOf(second) != here.ID || env.reload(t, first.ID) == nil {
|
||||
t.Fatalf("organisation_id after the unlink = %d, %d", organisationOf(first), organisationOf(second))
|
||||
}
|
||||
if got := members(here); !reflect.DeepEqual(got, []uint{second.ID}) {
|
||||
t.Fatalf("members after the unlink = %v", got)
|
||||
}
|
||||
untouched()
|
||||
|
||||
// Once released there, the user can be linked here: this is how a user
|
||||
// moves between organisations.
|
||||
if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK {
|
||||
t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID {
|
||||
t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
untouched()
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestAdminUsersTracer(t *testing.T) {
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav)
|
||||
if len(nav) != 1 || nav[0].Code != "user" || nav[0].Order != 555 || nav[0].Controller != "golem15.user.users" ||
|
||||
len(nav[0].SideMenu) != 1 || nav[0].SideMenu[0].Code != "users" {
|
||||
len(nav[0].SideMenu) != 2 || nav[0].SideMenu[0].Code != "users" || nav[0].SideMenu[1].Code != "organisations" {
|
||||
t.Fatalf("navigation = %+v", nav)
|
||||
}
|
||||
nav = nil
|
||||
|
||||
@@ -13,6 +13,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
usersAdminController{app: app},
|
||||
usergroupsAdminController{app: appAccess(app)},
|
||||
organisationsAdminController{app: appAccess(app)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +48,13 @@ func init() {
|
||||
var _ pact.FormBeforeDelete = usergroupsAdminController{}
|
||||
var _ pact.FormAfterDelete = usergroupsAdminController{}
|
||||
var _ cabana.PermissionEditorProvider = usergroupsAdminController{}
|
||||
|
||||
var _ pact.AdminController = organisationsAdminController{}
|
||||
var _ pact.AdminPermissioned = organisationsAdminController{}
|
||||
var _ pact.AdminRecordSource = organisationsAdminController{}
|
||||
var _ pact.FormBeforeCreate = organisationsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = organisationsAdminController{}
|
||||
var _ pact.FormBeforeDelete = organisationsAdminController{}
|
||||
var _ pact.RelationExtendManageQuery = organisationsAdminController{}
|
||||
var _ cabana.AdminRelationContractProvider = organisationsAdminController{}
|
||||
}
|
||||
|
||||
18
controllers/organisations/config_form.yaml
Normal file
18
controllers/organisations/config_form.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: golem15.user::lang.organisation.label
|
||||
form: ~/plugins/golem15/user/models/organisation/fields.yaml
|
||||
modelClass: Golem15\User\Models\Organisation
|
||||
defaultRedirect: golem15/user/organisations
|
||||
|
||||
create:
|
||||
redirect: golem15/user/organisations/update/:id
|
||||
redirectClose: golem15/user/organisations
|
||||
|
||||
update:
|
||||
redirect: golem15/user/organisations
|
||||
redirectClose: golem15/user/organisations
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.organisation.new
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleted: backend::lang.messages.form.deleted
|
||||
15
controllers/organisations/config_list.yaml
Normal file
15
controllers/organisations/config_list.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
list: ~/plugins/golem15/user/models/organisation/columns.yaml
|
||||
modelClass: Golem15\User\Models\Organisation
|
||||
title: golem15.user::lang.organisation.label_plural
|
||||
recordUrl: golem15/user/organisations/update/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.organisation.new
|
||||
empty: golem15.user::lang.organisation.list_empty
|
||||
24
controllers/organisations/config_relation.yaml
Normal file
24
controllers/organisations/config_relation.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
members:
|
||||
label: golem15.user::lang.organisation.members
|
||||
messages:
|
||||
link: golem15.user::lang.organisation.add_member
|
||||
unlinkSelected: golem15.user::lang.organisation.remove_members
|
||||
unlinkConfirm: golem15.user::lang.organisation.remove_members_confirm
|
||||
empty: golem15.user::lang.organisation.members_empty
|
||||
view:
|
||||
list:
|
||||
columns:
|
||||
name:
|
||||
label: golem15.user::lang.user.name
|
||||
email:
|
||||
label: golem15.user::lang.user.email
|
||||
toolbarButtons: link|unlink
|
||||
showSearch: true
|
||||
manage:
|
||||
list:
|
||||
columns:
|
||||
name:
|
||||
label: golem15.user::lang.user.name
|
||||
email:
|
||||
label: golem15.user::lang.user.email
|
||||
showSearch: true
|
||||
117
controllers/organisations_admin_controller.go
Normal file
117
controllers/organisations_admin_controller.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// organisationSlugPattern is the PHP alpha_dash rule on the slug, kept to
|
||||
// ASCII. lagoon.Validate has no alpha_dash token, so the Form before-hooks
|
||||
// check it.
|
||||
var organisationSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// membersRelation is the organisation's hasMany relation to its users.
|
||||
const membersRelation = "members"
|
||||
|
||||
// organisationsAdminController is the Winter Organisations backend
|
||||
// controller: the list, the form with the avatar, and the members relation
|
||||
// manager. The screen and every write need golem15.users.access_users, as in
|
||||
// PHP.
|
||||
//
|
||||
// The members manager sets and clears users.organisation_id and nothing
|
||||
// else. It never writes users_groups.
|
||||
type organisationsAdminController struct {
|
||||
app appAccess
|
||||
}
|
||||
|
||||
func (organisationsAdminController) ID() string { return "golem15.user.organisations" }
|
||||
|
||||
func (organisationsAdminController) ModelName() string { return `Golem15\User\Models\Organisation` }
|
||||
|
||||
func (organisationsAdminController) ConfigDir() string { return "controllers/organisations" }
|
||||
|
||||
func (organisationsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionAccessUsers}
|
||||
}
|
||||
|
||||
func (organisationsAdminController) NewRecord() any { return &models.Organisation{} }
|
||||
|
||||
// FormBeforeCreate and FormBeforeUpdate check the slug's characters.
|
||||
func (c organisationsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
return c.requireSlugFormat(ctx, model)
|
||||
}
|
||||
|
||||
func (c organisationsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
return c.requireSlugFormat(ctx, model)
|
||||
}
|
||||
|
||||
// requireSlugFormat answers 422 on slug when it holds anything but letters,
|
||||
// digits, underscore and hyphen. An empty slug is left to the required rule.
|
||||
func (c organisationsAdminController) requireSlugFormat(ctx context.Context, model any) error {
|
||||
organisation, ok := model.(*models.Organisation)
|
||||
if !ok || organisation == nil {
|
||||
return errors.New("organisations admin: form model is not an organisation")
|
||||
}
|
||||
if organisation.Slug == "" || organisationSlugPattern.MatchString(organisation.Slug) {
|
||||
return nil
|
||||
}
|
||||
message := "The slug may only contain letters, numbers, dashes and underscores."
|
||||
if tr := c.app.translator(); tr != nil {
|
||||
const key = "golem15.user::lang.organisation.slug_invalid"
|
||||
if text := tr.Get(ctx, key, nil); text != "" && text != key {
|
||||
message = text
|
||||
}
|
||||
}
|
||||
return &cabana.ValidationError{Details: map[string]any{"slug": []string{message}}}
|
||||
}
|
||||
|
||||
// AdminRelationContracts declares the members relation manager: the users
|
||||
// whose organisation_id is the organisation. Linking sets the column and
|
||||
// unlinking clears it; the column is nullable, so a user is never deleted.
|
||||
func (organisationsAdminController) AdminRelationContracts() []cabana.RelationContract {
|
||||
return []cabana.RelationContract{{
|
||||
Name: membersRelation,
|
||||
Kind: cabana.RelationHasMany,
|
||||
NewRelated: func() any { return &models.User{} },
|
||||
ForeignKey: "organisation_id",
|
||||
Columns: map[string]string{
|
||||
"name": "name",
|
||||
"email": "email",
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// RelationExtendManageQuery fails closed: any relation name other than
|
||||
// members has no candidates.
|
||||
func (organisationsAdminController) RelationExtendManageQuery(_ context.Context, relation string, db *gorm.DB) *gorm.DB {
|
||||
if db == nil {
|
||||
return db
|
||||
}
|
||||
if relation != membersRelation {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// FormBeforeDelete releases the members of the organisation in the delete's
|
||||
// transaction: organisation_id and organisation_role are cleared, on
|
||||
// deactivated users too, so the delete does not fail on the users foreign key
|
||||
// and no user is deleted.
|
||||
func (organisationsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||
organisation, ok := model.(*models.Organisation)
|
||||
if !ok || organisation == nil {
|
||||
return errors.New("organisations admin: form model is not an organisation")
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("organisations admin: a delete needs its transaction")
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Model(&models.User{}).Where("organisation_id = ?", organisation.ID).
|
||||
Updates(map[string]any{"organisation_id": nil, "organisation_role": nil}).Error
|
||||
}
|
||||
@@ -96,4 +96,20 @@ groups:
|
||||
privileged_forbidden: You do not have permission to create, re-code or delete a privileged group. Nothing was saved.
|
||||
organisation:
|
||||
label: Organisation
|
||||
label_plural: Organisations
|
||||
menu_label: Organisations
|
||||
id: ID
|
||||
name: Name
|
||||
slug: Slug
|
||||
slug_comment: Auto-generated from the name if left blank.
|
||||
slug_invalid: The slug may only contain letters, numbers, dashes and underscores.
|
||||
description: Description
|
||||
avatar: Avatar
|
||||
members: Members
|
||||
new: New Organisation
|
||||
list_empty: No organisations yet. Create the first one with New Organisation.
|
||||
add_member: Add member
|
||||
remove_members: Remove selected
|
||||
remove_members_confirm: Remove the selected (:count) from this organisation? The users are not deleted.
|
||||
members_empty: This organisation has no members yet. Add users with Add member.
|
||||
empty_organisation: — No organisation —
|
||||
|
||||
@@ -96,4 +96,20 @@ groups:
|
||||
privileged_forbidden: Nie masz uprawnień do tworzenia, zmiany kodu ani usuwania grup uprzywilejowanych. Nic nie zostało zapisane.
|
||||
organisation:
|
||||
label: Organizacja
|
||||
label_plural: Organizacje
|
||||
menu_label: Organizacje
|
||||
id: ID
|
||||
name: Nazwa
|
||||
slug: Identyfikator
|
||||
slug_comment: Generowany automatycznie z nazwy, jeśli pozostawiony pusty.
|
||||
slug_invalid: Identyfikator może zawierać tylko litery, cyfry, myślniki i podkreślenia.
|
||||
description: Opis
|
||||
avatar: Awatar
|
||||
members: Członkowie
|
||||
new: Nowa organizacja
|
||||
list_empty: Brak organizacji. Utwórz pierwszą przyciskiem Nowa organizacja.
|
||||
add_member: Dodaj członka
|
||||
remove_members: Usuń zaznaczonych
|
||||
remove_members_confirm: Usunąć zaznaczonych (:count) z tej organizacji? Użytkownicy nie zostaną usunięci.
|
||||
members_empty: Ta organizacja nie ma jeszcze członków. Dodaj użytkowników przyciskiem Dodaj członka.
|
||||
empty_organisation: — Brak organizacji —
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
// Organisation is the structure-only FK target for Fonoteka org credentials.
|
||||
// Roles and membership behavior belong to Phase 7 AUTH-02.
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Organisation is a row of golem15_user_organisations, the PHP Organisation
|
||||
// model: a named group of users (users.organisation_id) that other plugins
|
||||
// hang organisation-level data on. The table is shared with those plugins
|
||||
// and gets no column from the admin screen.
|
||||
type Organisation struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
@@ -11,10 +18,50 @@ type Organisation struct {
|
||||
Description *string `gorm:"column:description"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
// Members is the PHP hasMany members relation: the users whose
|
||||
// organisation_id is this organisation. It is not a column. It is loaded
|
||||
// only on request, written only by the admin members manager (which sets
|
||||
// and clears users.organisation_id), and never serialized.
|
||||
Members []User `gorm:"foreignKey:OrganisationID;references:ID" json:"-"`
|
||||
}
|
||||
|
||||
func (Organisation) TableName() string { return "golem15_user_organisations" }
|
||||
|
||||
// MorphName is the PHP class string stored in system_files.attachment_type.
|
||||
func (Organisation) MorphName() string { return `Golem15\User\Models\Organisation` }
|
||||
|
||||
// OrganisationAvatarField is the attachOne relation name of the
|
||||
// organisation's picture.
|
||||
const OrganisationAvatarField = "avatar"
|
||||
|
||||
// AttachRelations declares the public attachOne avatar (PHP $attachOne).
|
||||
func (Organisation) AttachRelations() []attach.Relation {
|
||||
return []attach.Relation{{Name: OrganisationAvatarField, Public: true}}
|
||||
}
|
||||
|
||||
// Fillable is the PHP $fillable list.
|
||||
func (Organisation) Fillable() []string {
|
||||
return []string{"name", "slug", "description"}
|
||||
}
|
||||
|
||||
// Rules is the PHP rule set without alpha_dash on slug, which the rule
|
||||
// engine does not know: the admin controller checks the slug's characters.
|
||||
func (Organisation) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|max:255",
|
||||
"slug": "required|unique:golem15_user_organisations",
|
||||
"description": "nullable|max:5000",
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeValidate fills an empty slug from the name, the PHP beforeValidate.
|
||||
func (o *Organisation) BeforeValidate(_ *gorm.DB) error {
|
||||
if o.Slug == "" && o.Name != "" {
|
||||
o.Slug = Slugify(o.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Organisation{})
|
||||
}
|
||||
|
||||
14
models/organisation/columns.yaml
Normal file
14
models/organisation/columns.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
columns:
|
||||
id:
|
||||
label: golem15.user::lang.organisation.id
|
||||
invisible: true
|
||||
name:
|
||||
label: golem15.user::lang.organisation.name
|
||||
searchable: true
|
||||
slug:
|
||||
label: golem15.user::lang.organisation.slug
|
||||
searchable: true
|
||||
created_at:
|
||||
label: backend::lang.list.column_created
|
||||
type: datetime
|
||||
sortable: true
|
||||
30
models/organisation/fields.yaml
Normal file
30
models/organisation/fields.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
fields:
|
||||
name:
|
||||
label: golem15.user::lang.organisation.name
|
||||
type: text
|
||||
span: left
|
||||
required: true
|
||||
slug:
|
||||
label: golem15.user::lang.organisation.slug
|
||||
comment: golem15.user::lang.organisation.slug_comment
|
||||
type: text
|
||||
span: right
|
||||
preset:
|
||||
field: name
|
||||
type: slug
|
||||
description:
|
||||
label: golem15.user::lang.organisation.description
|
||||
type: textarea
|
||||
size: small
|
||||
avatar:
|
||||
label: golem15.user::lang.organisation.avatar
|
||||
type: fileupload
|
||||
mode: image
|
||||
imageWidth: 120
|
||||
imageHeight: 120
|
||||
members:
|
||||
type: relation-manager
|
||||
relation: members
|
||||
tab: golem15.user::lang.organisation.members
|
||||
span: full
|
||||
context: update
|
||||
26
models/slug.go
Normal file
26
models/slug.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// Slugify turns text into a slug by the rule the admin form's slug preset
|
||||
// uses: lower-case ASCII letters and digits are kept, every run of other
|
||||
// characters becomes one hyphen, and there is no hyphen at either end.
|
||||
// Letters outside ASCII are not transliterated; they count as other
|
||||
// characters. A text without a letter or digit gives the empty string.
|
||||
func Slugify(text string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(text))
|
||||
gap := false
|
||||
for _, r := range strings.ToLower(text) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
if gap && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
gap = false
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
gap = true
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user