From 8cb56a4c519afd21c02c9f5f456671c31b095633 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 21:32:19 +0100 Subject: [PATCH] feat(cabana): list the administrators holding a role on the Roles form (quick-261028-rqg) - users form partial on the Administrators tab of the role update form, served by rolesAdmin.PartialData through scopeAdministrators (D-03) - deleted and deactivated holders marked, superusers only for superuser editors, first 100 rows with a count note, preview links for editors holding backend.manage_users - en/pl phrases, README and docs, TestRolesSmoke asserts the partial --- docs/backend/users-and-permissions.md | 1 + modules/cabana/README.md | 2 +- modules/cabana/backend_roles_admin.go | 116 ++++++++++++++++++ modules/cabana/backend_screens_smoke_test.go | 6 + .../controllers/roles/_users.htm | 18 +++ .../models/userrole/fields.yaml | 8 ++ modules/phrasebook/backend/lang/en/lang.yaml | 6 + modules/phrasebook/backend/lang/pl/lang.yaml | 8 ++ 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 modules/cabana/backendscreens/controllers/roles/_users.htm diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index be2f06c0..5f8619b8 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -186,6 +186,7 @@ Settings → System → Roles (`summercms.cabana.roles`, `cabana.RolesController - **System roles** are the roles with `is_system` set (the seeded developer and publisher roles) and every role whose code plugins grant permissions to through `pact.Permission` `Roles`. Their code cannot be changed and they cannot be deleted; name, description and stored grants stay editable. A role created on this screen is never a system role. The grants plugins declare in code are merged with the stored ones when an administrator signs in, so they apply whether or not they are stored. The role's editor shows them checked and locked, with the note that a plugin grants them; a save keeps them as stored. - An administrator who is not a superuser cannot grant more than they hold: permissions they lack are locked in the role's editor, so they can neither add nor remove them on any role. - A role that any administrator holds, deleted administrators included, cannot be deleted; the refusal names the role and how many administrators hold it. Give them another role first. A refused role in a bulk delete rolls back the whole selection. +- The Administrators tab of a saved role lists the administrators who hold it, with login, first name, last name and email, sorted by login. It is read-only: roles are assigned on the Administrators form. Superusers appear only to an editor who is a superuser; deleted and deactivated administrators are listed and marked, so the list matches the count of the delete refusal. Up to 100 administrators are shown, with a note giving the total beyond that. For an editor who also holds `backend.manage_users` (`cabana.PermissionManageUsers`), each login opens that administrator's preview. ### Operator commands diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 6307e6eb..eb33a0ab 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -54,7 +54,7 @@ Use cases: - Tag lists: a `type: taglist` field in `fields.yaml` edits a list of tags (WinterCMS TagList). `mode: array` binds a JSON column (a type with `sql.Scanner` and `driver.Valuer`, such as `lagoon.Jsonable[[]string]`) and travels as a JSON array of strings; `mode: string`, the default, binds a string column. `options` is a YAML list of suggested tags; `separator` (`comma` or `space`, string mode only) and `customTags` are refused on other types, and an unknown mode or separator, options that are not a list, the money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset` or a column of the wrong Go type fails boot naming the field. A save trims every tag, drops empty ones and keeps a repeated tag once; in array mode null or an empty list stores `[]`, in string mode the tags are split on the separator and stored joined by `,` or a single space (null stores an empty string). A value of the wrong shape is a 422 on the field with `backend::lang.taglist.invalid`, and with `customTags: false` a tag outside `options` is a 422 with `backend::lang.taglist.not_allowed` (`:tag`), on controller forms, settings forms and relation forms alike; a taglist in a repeater row fails boot. The record GET serves the stored list (array mode) or string (string mode). The schema serves `mode`, `options`, `separator` and `customTags` on the field; the admin edits the tags as chips. - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - Refusals: a lifecycle hook, a relation hook, a bulk, record, toolbar or widget action, a `pact.ReorderAfterSave` hook or a `pact.AdminSingleton` `SingletonRecord` call that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. A `cabana.ValidationError` returned from the same places is answered 422 `validation_failed` with its `Details`, each message that is a translation key (`vendor.plugin::group.key`) translated in the request locale and any other text passed through unchanged. The plugin's error value is never modified, so it may be a shared variable. The surrounding transaction is rolled back. Every other error stays the opaque 500, logged on the server. -- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. +- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. The update form's Administrators tab lists the role's holders read-only (a form partial): superusers only for superuser editors, deleted and deactivated holders marked, the first 100 with a note naming the total, each login linked to the Administrators preview for an editor holding `cabana.PermissionManageUsers`; roles are assigned only on the Administrators form. - Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active. - Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name. - Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin. diff --git a/modules/cabana/backend_roles_admin.go b/modules/cabana/backend_roles_admin.go index 38e58117..a74cb0b7 100644 --- a/modules/cabana/backend_roles_admin.go +++ b/modules/cabana/backend_roles_admin.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "strconv" + "strings" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" @@ -37,6 +39,7 @@ var ( _ pact.FormBeforeUpdate = rolesAdmin{} _ pact.FormAfterUpdate = rolesAdmin{} _ pact.FormBeforeDelete = rolesAdmin{} + _ pact.AdminPartialData = rolesAdmin{} _ PermissionEditorProvider = rolesAdmin{} _ PermissionRecordLockProvider = rolesAdmin{} ) @@ -63,6 +66,19 @@ func (c rolesAdmin) translator() *phrasebook.Translator { return tr } +// db is the application pool, or nil without an application or pool. +func (c rolesAdmin) db() *gorm.DB { + app := c.screens.application() + if app == nil { + return nil + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok { + return nil + } + return gdb +} + func asRole(model any) (*BackendUserRole, error) { role, ok := model.(*BackendUserRole) if !ok || role == nil { @@ -288,3 +304,103 @@ func (c rolesAdmin) FormBeforeDelete(ctx context.Context, model any) error { } return &ForbiddenError{Message: message} } + +// roleHoldersLimit caps the rows of the update form's holders list; past it +// the list ends with a note naming the total. +const roleHoldersLimit = 100 + +// roleHoldersView is the curated view model of the users partial: the +// role's holders as the editor may see them (D-03), whether the role has +// none, and the translated note when more than roleHoldersLimit hold it. +// The zero value renders nothing (the create form has no record). +type roleHoldersView struct { + Rows []roleHolderRow + Empty bool + Note string +} + +// roleHolderRow is one holder: plain strings only, the Administrators +// preview link when the editor may manage administrators, and the phrase +// keys of its states (Superuser, Deleted, Deactivated). +type roleHolderRow struct { + Login string + FirstName string + LastName string + Email string + Link string + States []string +} + +// PartialData serves the update form's users partial, the read-only list of +// the administrators holding the role (WinterCMS UserRoles' _users). The +// query and the total go through scopeAdministrators, so deleted holders +// are listed, matching the delete refusal's count (D-17), and an editor who +// does not act as a superuser never sees a superuser holder, neither as a +// row nor in the total (D-03). Roles are assigned only on the +// Administrators form; this list changes nothing. +func (c rolesAdmin) PartialData(ctx context.Context, name string, record any) (any, error) { + if name != "users" { + return nil, fmt.Errorf("cabana: roles: unknown partial %q", name) + } + role, ok := record.(*BackendUserRole) + if !ok || role == nil || role.ID == 0 { + return roleHoldersView{}, nil + } + db := c.db() + if db == nil { + return nil, errors.New("cabana: roles: the holders list needs the database") + } + base := scopeAdministrators(ctx, db.WithContext(ctx).Model(&BackendUser{})). + Where("backend_users.role_id = ?", role.ID) + var total int64 + if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { + return nil, err + } + view := roleHoldersView{Empty: total == 0} + if total == 0 { + return view, nil + } + var users []BackendUser + if err := base.Session(&gorm.Session{}). + Select("backend_users.id", "backend_users.login", "backend_users.first_name", "backend_users.last_name", + "backend_users.email", "backend_users.is_activated", "backend_users.is_superuser", "backend_users.deleted_at"). + Order("backend_users.login").Order("backend_users.id"). + Limit(roleHoldersLimit).Find(&users).Error; err != nil { + return nil, err + } + principal, _ := bouncer.User(ctx) + linkBase := "" + if Allows(principal, []string{PermissionManageUsers}) { + prefix, err := AdminPrefix(c.screens.application()) + if err != nil { + return nil, err + } + linkBase = prefix + "/" + strings.ReplaceAll(AdministratorsControllerID, ".", "/") + "/" + } + view.Rows = make([]roleHolderRow, 0, len(users)) + for _, user := range users { + row := roleHolderRow{Login: user.Login, FirstName: user.FirstName, LastName: user.LastName, Email: user.Email} + if linkBase != "" { + row.Link = linkBase + strconv.FormatUint(uint64(user.ID), 10) + "/preview" + } + if user.IsSuperuser { + row.States = append(row.States, "backend::lang.user.superuser") + } + if user.DeletedAt.Valid { + row.States = append(row.States, "backend::lang.user.state_deleted") + } + if !user.IsActivated { + row.States = append(row.States, "backend::lang.user.state_deactivated") + } + view.Rows = append(view.Rows, row) + } + if total > roleHoldersLimit { + key := rolesLang + "users_more" + params := map[string]string{"shown": strconv.Itoa(roleHoldersLimit), "count": strconv.FormatInt(total, 10)} + view.Note = key + if tr := c.translator(); tr != nil { + view.Note = tr.Choice(towel.WithLocale(ctx, schemaLocale(ctx, tr)), key, total, params) + } + } + return view, nil +} diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go index b447eca9..fd3f2dd8 100644 --- a/modules/cabana/backend_screens_smoke_test.go +++ b/modules/cabana/backend_screens_smoke_test.go @@ -268,6 +268,12 @@ func TestRolesSmoke(t *testing.T) { if got := env.storedUser(t, holder.ID); got.RoleID == nil || *got.RoleID != roleID { t.Fatalf("assigned role = %v", got.RoleID) } + // The update form's Administrators tab lists the holder, linked to its preview. + holders := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/users?id=%d", rolesPath, roleID), rootToken, nil) + if text := fmt.Sprint(holders["data"]); !strings.Contains(text, holder.Login) || + !strings.Contains(text, fmt.Sprintf("/summercms/cabana/users/%d/preview", holder.ID)) { + t.Fatalf("holders partial = %s", text) + } env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil, "“"+name+"” is assigned to 1 administrator.") diff --git a/modules/cabana/backendscreens/controllers/roles/_users.htm b/modules/cabana/backendscreens/controllers/roles/_users.htm new file mode 100644 index 00000000..8cc055fa --- /dev/null +++ b/modules/cabana/backendscreens/controllers/roles/_users.htm @@ -0,0 +1,18 @@ +{{- /* Read-only holders of the role on its update form: a table, the empty line, or nothing (roleHoldersView). */ -}} +{{- if .Data.Rows -}} +
+ + + +{{- range .Data.Rows -}} + +{{- end -}} + +
{{ trans "backend::lang.user.login" }}{{ trans "backend::lang.user.first_name" }}{{ trans "backend::lang.user.last_name" }}{{ trans "backend::lang.user.email" }}
{{ if .Link }}{{ .Login }}{{ else }}{{ .Login }}{{ end }}{{ range .States }}
{{ trans . }}
{{ end }}
{{ .FirstName }}{{ .LastName }}{{ .Email }}
+
+{{- if .Data.Note }} +

{{ .Data.Note }}

+{{- end -}} +{{- else if .Data.Empty -}} +

{{ trans "backend::lang.user.role.users_empty" }}

+{{- end -}} diff --git a/modules/cabana/backendscreens/models/userrole/fields.yaml b/modules/cabana/backendscreens/models/userrole/fields.yaml index b9e7812b..3fdd0dfb 100644 --- a/modules/cabana/backendscreens/models/userrole/fields.yaml +++ b/modules/cabana/backendscreens/models/userrole/fields.yaml @@ -24,3 +24,11 @@ fields: mode: checkbox span: full tab: backend::lang.user.permissions + users: + label: backend::lang.user.role.users_field + comment: backend::lang.user.role.users_comment + type: partial + path: users + context: update + span: full + tab: backend::lang.user.menu_label diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 9ecdc5dc..6865d128 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -537,6 +537,12 @@ user: delete_system: "System roles cannot be deleted." code_locked: "The code of a system role cannot be changed." code_taken: "This code is already used by another role." + users_field: "Administrators with this role" + users_comment: "Read-only. Roles are assigned on the Administrators screen." + users_empty: "No administrator has this role." + users_more: + one: "Showing the first :shown of :count administrator. Filter the Administrators list by this role to see all of them." + other: "Showing the first :shown of :count administrators. Filter the Administrators list by this role to see all of them." permissions: tab_system: "System" manage_users: "Manage administrators" diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 598c7d88..7acde0da 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -571,6 +571,14 @@ user: delete_system: "Ról systemowych nie można usunąć." code_locked: "Kodu roli systemowej nie można zmienić." code_taken: "Ten kod jest już używany przez inną rolę." + users_field: "Administratorzy z tą rolą" + users_comment: "Tylko do odczytu. Role przydziela się na ekranie Administratorzy." + users_empty: "Żaden administrator nie ma tej roli." + users_more: + one: "Pokazano pierwszych :shown z :count administratora. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." + few: "Pokazano pierwszych :shown z :count administratorów. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." + many: "Pokazano pierwszych :shown z :count administratorów. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." + other: "Pokazano pierwszych :shown z :count administratora. Aby zobaczyć wszystkich, przefiltruj listę Administratorzy według tej roli." permissions: tab_system: "System" manage_users: "Zarządzanie administratorami"