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:
Jakub Zych
2026-10-05 13:58:54 +02:00
parent a3992879db
commit 58595fef27
16 changed files with 828 additions and 5 deletions

View File

@@ -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 }

View File

@@ -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"}) {

View File

@@ -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
View 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()
}

View File

@@ -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

View File

@@ -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{}
}

View 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

View 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

View 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

View 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
}

View File

@@ -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 —

View File

@@ -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 —

View File

@@ -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{})
}

View 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

View 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
View 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()
}