diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index 344577b4..1aca2004 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -175,6 +175,14 @@ When a refused row is part of a bulk selection, the whole selection is rolled ba Setting the password of another administrator ends that administrator's sessions at once, as `admin:reset-password` does: tokens issued before the change are refused. Their personal API tokens keep working, exactly as after `admin:reset-password`; revoke them with `admin:token:revoke` when they should stop too. +### The Roles screen + +Settings → System → Roles (`summercms.cabana.roles`, `cabana.RolesControllerID`) lists, creates, edits and deletes the roles in `backend_user_roles`; it needs `backend.manage_roles`. A role has a name (unique), a code (filled from the name until you edit it, and unique too), a description, and on the Permissions tab the permission codes it grants, stored in the role's `permissions`. + +- **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. +- 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. + ### Operator commands The application binary has commands for operators: diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 319485d4..15b26cf6 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -53,6 +53,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. - 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. 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. - 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. - Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant. @@ -231,7 +232,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | Identifier | Description | |------------|-------------| -| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation, permissions and record action extensions, plus the admin's own Administrators screen, and returns the admin `cabana.Routes`, or nil when no plugin registers a controller. | +| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation, permissions and record action extensions, plus the admin's own Administrators and Roles screens, and returns the admin `cabana.Routes`, or nil when no plugin registers a controller. | | `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. | | `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. | | `cabana.RuntimeCommands` | Returns the `admin:create`, `admin:reset-password` and `admin:token:*` commands. | diff --git a/modules/cabana/backend_roles_admin.go b/modules/cabana/backend_roles_admin.go new file mode 100644 index 00000000..f93292fe --- /dev/null +++ b/modules/cabana/backend_roles_admin.go @@ -0,0 +1,211 @@ +package cabana + +import ( + "context" + "errors" + "fmt" + "strconv" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/towel" + "gorm.io/gorm" +) + +// rolesLang prefixes the phrase keys of the Roles screen. +const rolesLang = "backend::lang.user.role." + +// rolesPermissionsField is the Roles form's permission editor field. +const rolesPermissionsField = "permissions" + +// rolesAdmin is the Roles screen (summercms.cabana.roles), the WinterCMS +// Backend\Controllers\UserRoles controller over backend_user_roles. Every +// route needs backend.manage_roles. +type rolesAdmin struct { + screens *backendScreens +} + +var ( + _ pact.AdminController = rolesAdmin{} + _ pact.AdminPermissioned = rolesAdmin{} + _ pact.AdminRecordSource = rolesAdmin{} + _ pact.FormRecordTitle = rolesAdmin{} + _ pact.FormBeforeCreate = rolesAdmin{} + _ pact.FormBeforeUpdate = rolesAdmin{} + _ pact.FormBeforeDelete = rolesAdmin{} + _ PermissionEditorProvider = rolesAdmin{} +) + +func (rolesAdmin) ID() string { return RolesControllerID } + +func (rolesAdmin) ModelName() string { return `Backend\Models\UserRole` } + +func (rolesAdmin) ConfigDir() string { return "controllers/roles" } + +func (rolesAdmin) RequiredPermissions() []string { return []string{PermissionManageRoles} } + +func (rolesAdmin) NewRecord() any { return &BackendUserRole{} } + +func (c rolesAdmin) registry() *Registry { return c.screens.registry() } + +// translator is the application's translator, or nil. +func (c rolesAdmin) translator() *phrasebook.Translator { + app := c.screens.application() + if app == nil { + return nil + } + tr, _ := app.Lookup[*phrasebook.Translator]() + return tr +} + +func asRole(model any) (*BackendUserRole, error) { + role, ok := model.(*BackendUserRole) + if !ok || role == nil { + return nil, errors.New("cabana: roles: record is not a backend role") + } + return role, nil +} + +// FormRecordTitle names a role by its name. +func (rolesAdmin) FormRecordTitle(_ context.Context, _ *gorm.DB, record any) (string, error) { + role, err := asRole(record) + if err != nil { + return "", err + } + return role.Name, nil +} + +// systemRole reports whether a role with this flag and code is a system +// role: is_system, or a code that code-declared grants (pact.Permission +// Roles) attach to. Its code cannot change and it cannot be deleted. +func (c rolesAdmin) systemRole(isSystem bool, code string) bool { + return isSystem || len(c.registry().rolePermissions(code)) > 0 +} + +// writeDB is the save's transaction as a fresh session. +func writeDB(ctx context.Context) (*gorm.DB, error) { + tx, ok := TxFromContext(ctx) + if !ok { + return nil, errors.New("cabana: roles: a save needs its transaction") + } + return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx), nil +} + +// AdminPermissionOptions offers the whole permission catalog as checkboxes; +// a code the editor does not hold is locked (D-07), so a non-superuser can +// neither add nor remove it on any role. +func (c rolesAdmin) AdminPermissionOptions(ctx context.Context, field string) ([]PermissionOption, error) { + if field != rolesPermissionsField { + return nil, fmt.Errorf("cabana: roles: unknown permission field %q", field) + } + return catalogOptions(ctx, c.registry()), nil +} + +// AdminPermissionValues decodes the role's stored grants (value 1). +func (rolesAdmin) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) { + role, ok := record.(*BackendUserRole) + if field != rolesPermissionsField || !ok || role == nil { + return nil, fmt.Errorf("cabana: roles: cannot read permission field %q", field) + } + return decodePermissionValues(role.Permissions, false), nil +} + +// AdminSetPermissionValues stores the grants on the role; the save writes +// the row. +func (rolesAdmin) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error { + role, ok := record.(*BackendUserRole) + if field != rolesPermissionsField || !ok || role == nil { + return fmt.Errorf("cabana: roles: cannot write permission field %q", field) + } + encoded, err := encodePermissionValues(values) + if err != nil { + return err + } + role.Permissions = encoded + return nil +} + +// FormBeforeCreate refuses a code another role uses. A role created here is +// never a system role: is_system is not fillable. +func (c rolesAdmin) FormBeforeCreate(ctx context.Context, model any) error { + role, err := asRole(model) + if err != nil { + return err + } + return c.checkCode(ctx, role) +} + +// FormBeforeUpdate refuses a change of a system role's code (D-16) and a +// code another role uses. The code is fillable, so the stored code is read +// again through the save's transaction. +func (c rolesAdmin) FormBeforeUpdate(ctx context.Context, model any) error { + role, err := asRole(model) + if err != nil { + return err + } + db, err := writeDB(ctx) + if err != nil { + return err + } + var stored BackendUserRole + if err := db.Select("id", "code", "is_system").Where("id = ?", role.ID).Take(&stored).Error; err != nil { + return err + } + if c.systemRole(stored.IsSystem, stored.Code) && role.Code != stored.Code { + return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_locked"}}} + } + return c.checkCode(ctx, role) +} + +// checkCode refuses a code another role already uses (exact match): the +// database has only an index on code, and code lookups fail on an +// ambiguous one. +func (rolesAdmin) checkCode(ctx context.Context, role *BackendUserRole) error { + db, err := writeDB(ctx) + if err != nil { + return err + } + var taken int64 + if err := db.Model(&BackendUserRole{}).Where("code = ? AND id <> ?", role.Code, role.ID).Count(&taken).Error; err != nil { + return err + } + if taken > 0 { + return &ValidationError{Details: map[string]any{"code": []string{rolesLang + "code_taken"}}} + } + return nil +} + +// FormBeforeDelete refuses deleting a system role and a role any +// administrator holds, deleted administrators included: they still +// reference it (D-17). The message names the role and the count, already +// translated in the request locale, because a refusal carries no +// placeholders. Roles have no deleted_at, so the delete that follows is a +// hard delete of an unused role. +func (c rolesAdmin) FormBeforeDelete(ctx context.Context, model any) error { + role, err := asRole(model) + if err != nil { + return err + } + if c.systemRole(role.IsSystem, role.Code) { + return &ForbiddenError{Message: rolesLang + "delete_system"} + } + db, err := writeDB(ctx) + if err != nil { + return err + } + var holders int64 + if err := db.Unscoped().Model(&BackendUser{}).Where("role_id = ?", role.ID).Count(&holders).Error; err != nil { + return err + } + if holders == 0 { + return nil + } + tr := c.translator() + key := rolesLang + "delete_has_users" + params := map[string]string{"name": role.Name, "count": strconv.FormatInt(holders, 10)} + message := key + if tr != nil { + message = tr.Choice(towel.WithLocale(ctx, schemaLocale(ctx, tr)), key, holders, params) + } + return &ForbiddenError{Message: message} +} diff --git a/modules/cabana/backend_screens.go b/modules/cabana/backend_screens.go index 76b91a53..e26c24ee 100644 --- a/modules/cabana/backend_screens.go +++ b/modules/cabana/backend_screens.go @@ -74,7 +74,7 @@ func (*backendScreens) AdminFS() fs.FS { } func (s *backendScreens) AdminControllers() []pact.AdminController { - return []pact.AdminController{usersAdmin{screens: s}} + return []pact.AdminController{usersAdmin{screens: s}, rolesAdmin{screens: s}} } // Permissions declares the two screen permissions with no role grants @@ -100,6 +100,16 @@ func (*backendScreens) Settings() []pact.SettingsItem { Permissions: []string{PermissionManageUsers}, Controller: AdministratorsControllerID, }, + { + Code: "roles", + Label: "backend::lang.user.role.menu_label", + Description: "backend::lang.user.role.menu_description", + Category: "backend::lang.settings.category_system", + Icon: "shield-check", + Order: 110, + Permissions: []string{PermissionManageRoles}, + Controller: RolesControllerID, + }, } } diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go index 93391b5d..7eb9f47a 100644 --- a/modules/cabana/backend_screens_smoke_test.go +++ b/modules/cabana/backend_screens_smoke_test.go @@ -467,3 +467,73 @@ func TestAdministratorsLifecycleSmoke(t *testing.T) { t.Fatalf("activated target = %+v", got) } } + +const rolesPath = "/summercms/cabana/roles" + +func TestRolesSmoke(t *testing.T) { + env := newScreensEnv(t) + _, rootToken := env.admin(t, "roles-root", true, "") + holder, _ := env.admin(t, "roles-holder", false, "") + + name := "Editors " + env.stamp + created := env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, rootToken, map[string]any{ + "name": name, "code": "editors-" + env.stamp, "description": "Edit things", + "permissions": map[string]any{"acme.demo.access": 1}, + }) + data, _ := created["data"].(map[string]any) + roleID := uint(data["id"].(float64)) + var stored cabana.BackendUserRole + if err := env.gdb.First(&stored, roleID).Error; err != nil { + t.Fatal(err) + } + if stored.Permissions != `{"acme.demo.access":1}` || stored.IsSystem { + t.Fatalf("created role = %+v", stored) + } + // A code another role uses is refused on the field. + details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, rootToken, + map[string]any{"name": "Other " + env.stamp, "code": "editors-" + env.stamp}, "") + if !strings.Contains(fmt.Sprint(details["code"]), "already used") { + t.Fatalf("taken code details = %v", details) + } + + // Assign it through the Administrators form, then the role cannot go. + env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken, + map[string]any{"login": holder.Login, "email": holder.Email, "role": roleID}) + if got := env.storedUser(t, holder.ID); got.RoleID == nil || *got.RoleID != roleID { + t.Fatalf("assigned role = %v", got.RoleID) + } + env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil, + "“"+name+"” is assigned to 1 administrator.") + + var developer cabana.BackendUserRole + if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil { + t.Fatal(err) + } + env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, nil, + "System roles cannot be deleted.") + details = env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, + map[string]any{"name": developer.Name, "code": "developer-renamed"}, "") + if !strings.Contains(fmt.Sprint(details["code"]), "cannot be changed") { + t.Fatalf("system code details = %v", details) + } + // Name and description of a system role stay editable. + env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, + map[string]any{"name": developer.Name, "code": developer.Code, "description": developer.Description}) + + settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil) + var codes []string + entries, _ := settings["data"].([]any) + for _, entry := range entries { + item, _ := entry.(map[string]any) + if code, _ := item["code"].(string); code == "administrators" || code == "roles" { + codes = append(codes, code) + } + } + if fmt.Sprint(codes) != "[administrators roles]" { + t.Fatalf("superuser settings order = %v", codes) + } + // An unused role is deleted. + env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken, + map[string]any{"login": holder.Login, "email": holder.Email, "role": developer.ID}) + env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil) +} diff --git a/modules/cabana/backendscreens/controllers/roles/config_form.yaml b/modules/cabana/backendscreens/controllers/roles/config_form.yaml new file mode 100644 index 00000000..cd8f2675 --- /dev/null +++ b/modules/cabana/backendscreens/controllers/roles/config_form.yaml @@ -0,0 +1,19 @@ +name: backend::lang.user.role.name +form: ~/plugins/summercms/cabana/models/userrole/fields.yaml +modelClass: Backend\Models\UserRole +defaultRedirect: summercms/cabana/roles + +create: + redirect: summercms/cabana/roles/update/:id + redirectClose: summercms/cabana/roles + +update: + redirect: summercms/cabana/roles/update/:id + redirectClose: summercms/cabana/roles + +messages: + create: backend::lang.user.role.new + update: backend::lang.user.role.edit + saved: backend::lang.messages.form.saved + deleteConfirm: backend::lang.user.role.delete_confirm + deleted: backend::lang.messages.form.deleted diff --git a/modules/cabana/backendscreens/controllers/roles/config_list.yaml b/modules/cabana/backendscreens/controllers/roles/config_list.yaml new file mode 100644 index 00000000..161e4354 --- /dev/null +++ b/modules/cabana/backendscreens/controllers/roles/config_list.yaml @@ -0,0 +1,19 @@ +list: ~/plugins/summercms/cabana/models/userrole/columns.yaml +modelClass: Backend\Models\UserRole +title: backend::lang.user.role.list_title +recordUrl: summercms/cabana/roles/update/:id +noRecordsMessage: backend::lang.user.role.list_empty +recordsPerPage: 20 +showCheckboxes: true +showSetup: true + +toolbar: + buttons: [create, delete] + search: + prompt: backend::lang.list.search_prompt + +messages: + create: backend::lang.user.role.new + empty: backend::lang.user.role.list_empty + deleteConfirm: backend::lang.user.role.delete_selected_confirm + deleted: backend::lang.messages.list.deleted diff --git a/modules/cabana/backendscreens/models/userrole/columns.yaml b/modules/cabana/backendscreens/models/userrole/columns.yaml new file mode 100644 index 00000000..94bb4c0d --- /dev/null +++ b/modules/cabana/backendscreens/models/userrole/columns.yaml @@ -0,0 +1,26 @@ +columns: + name: + label: backend::lang.user.role.name_field + searchable: true + sortable: true + code: + label: backend::lang.user.role.code_field + searchable: true + sortable: true + description: + label: backend::lang.user.role.description_field + searchable: true + is_system: + label: backend::lang.user.role.is_system + type: switch + sortable: true + created_at: + label: backend::lang.user.created_at + type: datetime + invisible: true + sortable: true + updated_at: + label: backend::lang.list.column_updated + type: datetime + invisible: true + sortable: true diff --git a/modules/cabana/backendscreens/models/userrole/fields.yaml b/modules/cabana/backendscreens/models/userrole/fields.yaml new file mode 100644 index 00000000..b9e7812b --- /dev/null +++ b/modules/cabana/backendscreens/models/userrole/fields.yaml @@ -0,0 +1,26 @@ +fields: + name: + label: backend::lang.user.role.name_field + comment: backend::lang.user.role.name_comment + type: text + span: left + code: + label: backend::lang.user.role.code_field + comment: backend::lang.user.role.code_comment + type: text + span: right + preset: + type: slug + field: name + description: + label: backend::lang.user.role.description_field + type: textarea + size: tiny + span: full + permissions: + label: backend::lang.user.role.permissions_field + comment: backend::lang.user.role.permissions_comment + type: permissioneditor + mode: checkbox + span: full + tab: backend::lang.user.permissions