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
This commit is contained in:
Jakub Zych
2026-10-28 21:32:19 +01:00
parent c8a22e3e84
commit 8cb56a4c51
8 changed files with 164 additions and 1 deletions

View File

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

View File

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

View File

@@ -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.")

View File

@@ -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 -}}
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th scope="col">{{ trans "backend::lang.user.login" }}</th><th scope="col">{{ trans "backend::lang.user.first_name" }}</th><th scope="col">{{ trans "backend::lang.user.last_name" }}</th><th scope="col">{{ trans "backend::lang.user.email" }}</th></tr></thead>
<tbody>
{{- range .Data.Rows -}}
<tr><td>{{ if .Link }}<a href="{{ .Link }}">{{ .Login }}</a>{{ else }}{{ .Login }}{{ end }}{{ range .States }}<div class="summer-table__sub">{{ trans . }}</div>{{ end }}</td><td>{{ .FirstName }}</td><td>{{ .LastName }}</td><td>{{ .Email }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- if .Data.Note }}
<p>{{ .Data.Note }}</p>
{{- end -}}
{{- else if .Data.Empty -}}
<p>{{ trans "backend::lang.user.role.users_empty" }}</p>
{{- end -}}

View File

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

View File

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

View File

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