From 58595fef2785943d5a60206f13eb63aae08b41f0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:58:54 +0200 Subject: [PATCH] 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 --- admin.go | 1 + admin_groups_test.go | 2 +- admin_navigation.go | 7 + admin_organisations_test.go | 482 ++++++++++++++++++ admin_users_test.go | 2 +- controllers/admin_registry.go | 10 + controllers/organisations/config_form.yaml | 18 + controllers/organisations/config_list.yaml | 15 + .../organisations/config_relation.yaml | 24 + controllers/organisations_admin_controller.go | 117 +++++ lang/en/lang.yaml | 16 + lang/pl/lang.yaml | 16 + models/organisation.go | 53 +- models/organisation/columns.yaml | 14 + models/organisation/fields.yaml | 30 ++ models/slug.go | 26 + 16 files changed, 828 insertions(+), 5 deletions(-) create mode 100644 admin_organisations_test.go create mode 100644 controllers/organisations/config_form.yaml create mode 100644 controllers/organisations/config_list.yaml create mode 100644 controllers/organisations/config_relation.yaml create mode 100644 controllers/organisations_admin_controller.go create mode 100644 models/organisation/columns.yaml create mode 100644 models/organisation/fields.yaml create mode 100644 models/slug.go diff --git a/admin.go b/admin.go index a24df59..d413047 100644 --- a/admin.go +++ b/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 } diff --git a/admin_groups_test.go b/admin_groups_test.go index ce4a1c5..36bf99c 100644 --- a/admin_groups_test.go +++ b/admin_groups_test.go @@ -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"}) { diff --git a/admin_navigation.go b/admin_navigation.go index e5fbc3b..5458b03 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -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", + }, }, }, } diff --git a/admin_organisations_test.go b/admin_organisations_test.go new file mode 100644 index 0000000..727e55a --- /dev/null +++ b/admin_organisations_test.go @@ -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() +} diff --git a/admin_users_test.go b/admin_users_test.go index effb881..5b27d39 100644 --- a/admin_users_test.go +++ b/admin_users_test.go @@ -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 diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 282d6ac..c62a735 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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{} } diff --git a/controllers/organisations/config_form.yaml b/controllers/organisations/config_form.yaml new file mode 100644 index 0000000..7ff6bb9 --- /dev/null +++ b/controllers/organisations/config_form.yaml @@ -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 diff --git a/controllers/organisations/config_list.yaml b/controllers/organisations/config_list.yaml new file mode 100644 index 0000000..ba87c60 --- /dev/null +++ b/controllers/organisations/config_list.yaml @@ -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 diff --git a/controllers/organisations/config_relation.yaml b/controllers/organisations/config_relation.yaml new file mode 100644 index 0000000..899ab05 --- /dev/null +++ b/controllers/organisations/config_relation.yaml @@ -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 diff --git a/controllers/organisations_admin_controller.go b/controllers/organisations_admin_controller.go new file mode 100644 index 0000000..0a783f8 --- /dev/null +++ b/controllers/organisations_admin_controller.go @@ -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 +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 2a2fd64..487fbbb 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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 — diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index cfead3a..b638254 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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 — diff --git a/models/organisation.go b/models/organisation.go index 7a58925..b4d8691 100644 --- a/models/organisation.go +++ b/models/organisation.go @@ -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{}) } diff --git a/models/organisation/columns.yaml b/models/organisation/columns.yaml new file mode 100644 index 0000000..eb8a433 --- /dev/null +++ b/models/organisation/columns.yaml @@ -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 diff --git a/models/organisation/fields.yaml b/models/organisation/fields.yaml new file mode 100644 index 0000000..b8679d5 --- /dev/null +++ b/models/organisation/fields.yaml @@ -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 diff --git a/models/slug.go b/models/slug.go new file mode 100644 index 0000000..69c23a7 --- /dev/null +++ b/models/slug.go @@ -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() +}