diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index d7519fe8..33ed19f0 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -493,3 +493,40 @@ Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` tex - `Danger: true` marks an action that moves money out or cannot be undone, such as a refund. The record response serializes it as `danger` on the `cabana.RecordAction` entry (the key is absent for an ordinary action, so existing payloads are unchanged), and the admin's confirm dialog then shows its confirm button in the danger variant; the footer button itself stays the same. The flag changes nothing on the server: permissions and `Applies` are checked as for any action. Record actions have their own namespace: a record action and a bulk action may share a name, such as `activate` here. `create` and `delete` are reserved. A name in `recordActions` that the controller does not register, a duplicate, or an action without a label stops the start-up. + +## Adding record actions to another controller + +A plugin can add record actions to an admin controller it does not own, such as the admin's own Administrators screen (`summercms.cabana.users`, `cabana.AdministratorsControllerID`) or another plugin's screen. The plugin, not a controller, implements `pact.HasAdminActionExtensions` and returns `pact.AdminActionExtension` entries, each naming the target `Controller` by ID and the `pact.AdminRecordAction` entries to add: + +```go src=modules/cabana/example_action_extension_test.go#ProfilesPlugin.AdminActionExtensions +// AdminActionExtensions adds an action to the Administrators screen. +func (ProfilesPlugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{{ + Name: "acme_unlink_profile", + Label: "acme.profiles::lang.unlink", + Confirm: "acme.profiles::lang.unlink_confirm", + Permissions: []string{"acme.profiles.manage_links"}, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) // the action's row-locked transaction + if !ok { + return pact.AdminRecordActionResult{}, errors.New("acme.profiles: no transaction") + } + err := tx.Where("backend_user_id = ?", in.RecordID).Delete(&ProfileLink{}).Error + return pact.AdminRecordActionResult{Message: "acme.profiles::lang.unlinked"}, err + }, + }}, + }} +} +``` + +The admin merges the contributions at boot, after the target's own record actions and in plugin activation order. From then on a contributed action is an ordinary record action of the target: + +- It is offered on the target's preview screen, after the owner's actions, while its `Applies` reports true, and it runs through the same `POST /{controller}/{id}/actions/{action}` route: the record is loaded through the target's `pact.FormExtendQuery` scope with a row lock, and `Run` receives it in `pact.AdminRecordActionInput`. +- Both the target controller's `RequiredPermissions` and the action's own `Permissions` apply, so an action on the Administrators screen needs `backend.manage_users` and its own code. Its `Permissions` must name codes some activated plugin registers. +- A record action carries no input beyond the record. + +The start-up stops on an unknown controller ID, a target whose `config_form.yaml` has no `preview` block (record actions are offered on the preview screen), a name the target already registers (its owner's, or an earlier contribution's), a reserved name (`create`, `delete`), a name that is not an identifier, an action without `Run`, or an empty label. Prefix contributed names with your plugin (`acme_...`) so they cannot collide with names the owner adds later. + +Contributions are compiled only when the admin is active, that is when some plugin registers admin controllers; a plugin that only adds actions does not switch the admin on. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 53765edd..f0b1ae77 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -53,6 +53,8 @@ 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. +- 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 and opens a preview of each, where record actions (the admin's own and contributed ones) are offered. 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. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full. @@ -229,7 +231,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | Identifier | Description | |------------|-------------| -| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. | +| `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.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. | @@ -278,6 +280,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. | | `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | +| `cabana.PermissionManageUsers` / `cabana.PermissionManageRoles` | The permission codes of the Administrators (`backend.manage_users`) and Roles (`backend.manage_roles`) screens, granted to no role in code. | +| `cabana.AdministratorsControllerID` / `cabana.RolesControllerID` | The admin controller IDs of the Administrators (`summercms.cabana.users`) and Roles (`summercms.cabana.roles`) screens; a plugin names the first in `pact.AdminActionExtension.Controller` to add record actions to it. | +| `cabana.BackendUser.Fillable` / `cabana.BackendUser.Rules` | The columns the Administrators screen fills from a form (login, email, first and last name) and their base rules. | +| `cabana.BackendUserRole.Fillable` / `cabana.BackendUserRole.Rules` | The columns a role form fills (name, code, description) and their base rules. | | `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. | | `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag, role and `preferences`. | | `cabana.AdminPreferences` | An administrator's UI preferences: `theme` (system, light or dark; system when unset) and `locale` (an admin locale code, or "" to follow the request locale). | diff --git a/modules/cabana/action_extensions.go b/modules/cabana/action_extensions.go new file mode 100644 index 00000000..dd8e39e1 --- /dev/null +++ b/modules/cabana/action_extensions.go @@ -0,0 +1,97 @@ +package cabana + +import ( + "fmt" + "strings" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// compileActionExtensions merges the record actions plugins add to admin +// controllers through pact.HasAdminActionExtensions (D-14) into the compiled +// controllers of reg, in plugin slice (activation) order. A contributed +// action is appended to the target form's offered record actions and to its +// RecordActions map, so the show response, the record action route and the +// permission validation of compileContributions treat it like the owner's. +// It must run after every controller is compiled and before +// compileContributions, which validates the actions' permissions. Every +// failure stops boot: an unknown controller ID, a target without a form +// preview block (record actions are offered on the preview screen), a name +// that is not an identifier, a reserved name, a name the target already +// registers, an action without Run or without a label. +func compileActionExtensions(reg *Registry, plugins []party.Plugin) error { + if reg == nil { + return fmt.Errorf("cabana: registry is nil") + } + for _, plugin := range plugins { + if plugin == nil { + continue + } + src, ok := plugin.(pact.HasAdminActionExtensions) + if !ok || src == nil { + continue + } + pluginID := plugin.ID() + for _, ext := range src.AdminActionExtensions() { + if len(ext.RecordActions) == 0 { + continue + } + cc, ok := reg.byID[ext.Controller] + if !ok || cc == nil { + return fmt.Errorf("cabana: plugin %s adds record actions to unknown admin controller %q", pluginID, ext.Controller) + } + if cc.Form == nil || cc.Form.preview == nil { + return fmt.Errorf("cabana: plugin %s adds record actions to admin controller %s, which has no preview block (record actions are offered on the preview screen)", pluginID, ext.Controller) + } + for _, action := range ext.RecordActions { + if err := checkContributedAction(cc, pluginID, ext.Controller, action); err != nil { + return err + } + // A new slice and map: the compiled backing array may be + // shared with another schema value. + names := make([]string, 0, len(cc.Form.recordActions)+1) + names = append(names, cc.Form.recordActions...) + cc.Form.recordActions = append(names, action.Name) + actions := make(map[string]pact.AdminRecordAction, len(cc.RecordActions)+1) + for name, registered := range cc.RecordActions { + actions[name] = registered + } + actions[action.Name] = action + cc.RecordActions = actions + if cc.contributed == nil { + cc.contributed = map[string]string{} + } + cc.contributed[action.Name] = pluginID + } + } + } + return nil +} + +// checkContributedAction applies compileRecordActions' checks to one +// contributed record action, plus a label and a name the target does not +// register yet (the owner's or an earlier contribution). +func checkContributedAction(cc *CompiledController, pluginID, controller string, action pact.AdminRecordAction) error { + prefix := fmt.Sprintf("cabana: plugin %s adds record action %q to admin controller %s", pluginID, action.Name, controller) + if !identifier(action.Name) { + return fmt.Errorf("%s: the name is not an identifier", prefix) + } + if builtinToolbarActions[action.Name] { + return fmt.Errorf("%s: the name is reserved (create, delete)", prefix) + } + if action.Run == nil { + return fmt.Errorf("%s: it has no Run function", prefix) + } + if strings.TrimSpace(action.Label) == "" { + return fmt.Errorf("%s: it needs a label", prefix) + } + if _, taken := cc.RecordActions[action.Name]; taken { + owner := cc.PluginID + if contributor, ok := cc.contributed[action.Name]; ok { + owner = contributor + } + return fmt.Errorf("%s: plugin %s already registers a record action with that name", prefix, owner) + } + return nil +} diff --git a/modules/cabana/backend_screens.go b/modules/cabana/backend_screens.go new file mode 100644 index 00000000..76b91a53 --- /dev/null +++ b/modules/cabana/backend_screens.go @@ -0,0 +1,129 @@ +package cabana + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// The admin's own screens for backend administrators and roles (Settings → +// System), registered by cabana itself through an internal pseudo-plugin. +const ( + // PermissionManageUsers opens the Administrators screen. No role is + // granted it in code: until an administrator grants it, only superusers + // reach the screen. + PermissionManageUsers = "backend.manage_users" + // PermissionManageRoles opens the Roles screen. No role is granted it in + // code either. + PermissionManageRoles = "backend.manage_roles" + // AdministratorsControllerID is the admin controller ID of the + // Administrators screen, the target other plugins name to add record + // actions to it (pact.HasAdminActionExtensions). + AdministratorsControllerID = "summercms.cabana.users" + // RolesControllerID is the admin controller ID of the Roles screen. + RolesControllerID = "summercms.cabana.roles" +) + +// backendScreensPluginID is the pseudo-plugin's ID. It is also the +// migration history ID of the backend tables (lagoon), which is untouched: +// the pseudo-plugin declares no migrations. +const backendScreensPluginID = "summercms.cabana" + +// backendScreensFS holds the screens' admin YAML and partials. all: keeps +// the partials, whose names start with an underscore. +// +//go:embed all:backendscreens +var backendScreensFS embed.FS + +// backendScreens is the internal pseudo-plugin that registers the +// Administrators and Roles screens, their two permissions and their settings +// entries. Activate appends it to the plugins only when some plugin +// registers admin controllers, so an application without an admin still +// boots without one. app is the activating application and reg the compiled +// registry, set once compilation is done; the controllers read both per +// request. +type backendScreens struct { + app *backpack.App + reg *Registry +} + +var ( + _ party.Plugin = (*backendScreens)(nil) + _ pact.AdminAssets = (*backendScreens)(nil) + _ pact.HasAdminControllers = (*backendScreens)(nil) + _ pact.HasPermissions = (*backendScreens)(nil) + _ pact.HasSettings = (*backendScreens)(nil) +) + +func (*backendScreens) ID() string { return backendScreensPluginID } +func (*backendScreens) Requires() []string { return nil } +func (*backendScreens) Register(*backpack.App) error { return nil } +func (*backendScreens) Boot(*backpack.App) error { return nil } + +// AdminFS serves the embedded tree rooted at backendscreens/, so its paths +// start at controllers/ and models/. +func (*backendScreens) AdminFS() fs.FS { + sub, err := fs.Sub(backendScreensFS, "backendscreens") + if err != nil { + return nil + } + return sub +} + +func (s *backendScreens) AdminControllers() []pact.AdminController { + return []pact.AdminController{usersAdmin{screens: s}} +} + +// Permissions declares the two screen permissions with no role grants +// (D-04): an upgrade gives no existing role access to the screens. +func (*backendScreens) Permissions() []pact.Permission { + return []pact.Permission{ + {Code: PermissionManageUsers, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_users"}, + {Code: PermissionManageRoles, Tab: "backend::lang.permissions.tab_system", Label: "backend::lang.permissions.manage_roles"}, + } +} + +// Settings links the screens from Settings → System; there is no navigation +// entry. +func (*backendScreens) Settings() []pact.SettingsItem { + return []pact.SettingsItem{ + { + Code: "administrators", + Label: "backend::lang.user.menu_label", + Description: "backend::lang.user.menu_description", + Category: "backend::lang.settings.category_system", + Icon: "users", + Order: 100, + Permissions: []string{PermissionManageUsers}, + Controller: AdministratorsControllerID, + }, + } +} + +// application is the activating app, or nil. +func (s *backendScreens) application() *backpack.App { + if s == nil { + return nil + } + return s.app +} + +// registry is the compiled registry, or nil before compilation finished. +func (s *backendScreens) registry() *Registry { + if s == nil { + return nil + } + return s.reg +} + +// withBackendScreens returns a new plugin slice with the pseudo-plugin +// appended, and the pseudo-plugin. plugins itself is never modified. +func withBackendScreens(app *backpack.App, plugins []party.Plugin) ([]party.Plugin, *backendScreens) { + screens := &backendScreens{app: app} + out := make([]party.Plugin, 0, len(plugins)+1) + out = append(out, plugins...) + return append(out, screens), screens +} diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go new file mode 100644 index 00000000..ec5f00ae --- /dev/null +++ b/modules/cabana/backend_screens_smoke_test.go @@ -0,0 +1,260 @@ +package cabana_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/surf" + "gorm.io/gorm" +) + +// pingSpy records the runs of the contributed acme_ping record action. +type pingSpy struct { + mu sync.Mutex + ids []uint64 + withTx []bool +} + +func (s *pingSpy) record(id uint64, tx bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.ids = append(s.ids, id) + s.withTx = append(s.withTx, tx) +} + +func (s *pingSpy) runs() ([]uint64, []bool) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]uint64(nil), s.ids...), append([]bool(nil), s.withTx...) +} + +// pingPlugin owns no admin controller; it only adds the acme_ping record +// action to the Administrators screen (pact.HasAdminActionExtensions). +type pingPlugin struct{ spy *pingSpy } + +func (pingPlugin) ID() string { return "acme.pinger" } +func (pingPlugin) Requires() []string { return nil } +func (pingPlugin) Register(*backpack.App) error { return nil } +func (pingPlugin) Boot(*backpack.App) error { return nil } +func (pingPlugin) Permissions() []pact.Permission { + return []pact.Permission{{Code: "acme.pinger.ping"}} +} +func (p pingPlugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{{ + Name: "acme_ping", Label: "Ping", Permissions: []string{"acme.pinger.ping"}, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + _, ok := cabana.TxFromContext(ctx) + if _, isUser := in.Record.(*cabana.BackendUser); !isUser { + return pact.AdminRecordActionResult{}, errors.New("acme_ping: record is not a backend user") + } + p.spy.record(in.RecordID, ok) + return pact.AdminRecordActionResult{Message: "Pinged"}, nil + }, + }}, + }} +} + +// screensEnv is one assembled admin with the demo plugin, the pinger and the +// admin's own screens, on the shared Postgres. +type screensEnv struct { + h http.Handler + gdb *gorm.DB + stamp string + spy *pingSpy +} + +func newScreensEnv(t *testing.T) *screensEnv { + t.Helper() + gdb := adminGorm(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-screens\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576, "admin.password.bcrypt_cost": 4} { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, adminSQL, gdb); err != nil { + t.Fatal(err) + } + spy := &pingSpy{} + plugins := []party.Plugin{demoPlugin{fsys: demoFS()}, pingPlugin{spy: spy}} + if err := phrasebook.Activate(app, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy} +} + +// admin creates an activated administrator named name+stamp with the +// developer role (or a role holding rolePermissions JSON when it is not +// empty), optionally a superuser, signs it in and returns it with its token. +func (e *screensEnv) admin(t *testing.T, name string, superuser bool, rolePermissions string) (cabana.BackendUser, string) { + t.Helper() + login := name + "-" + e.stamp + user := insertAdmin(t, e.gdb, login, login+"@example.test", adminTestPassword, true, false) + if superuser { + if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + user.IsSuperuser = true + } + if rolePermissions != "" { + var roleID uint + if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) + VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Role "+login, "role-"+login, rolePermissions).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("role: id=%d err=%v", roleID, err) + } + if err := e.gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, user.ID).Error; err != nil { + t.Fatal(err) + } + user.RoleID = &roleID + } + rec := postJSON(t, e.h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword}) + if rec.Code != http.StatusOK { + t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) + } + return user, accessToken(t, rec.Body.Bytes()) +} + +// call runs one admin API request with a Bearer token. +func (e *screensEnv) call(t *testing.T, method, rel, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, adminAPI(rel), reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept-Language", "en") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +func (e *screensEnv) expect(t *testing.T, status int, method, rel, token string, body any) map[string]any { + t.Helper() + rec := e.call(t, method, rel, token, body) + if rec.Code != status { + t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("%s %s body %s: %v", method, rel, rec.Body.String(), err) + } + return out +} + +// listIDs returns the ids of a list response's rows. +func listIDs(t *testing.T, body map[string]any) map[uint]bool { + t.Helper() + rows, _ := body["data"].([]any) + out := map[uint]bool{} + for _, row := range rows { + fields, _ := row.(map[string]any) + if id, ok := fields["id"].(float64); ok { + out[uint(id)] = true + } + } + return out +} + +// recordActionNames returns the names of a show response's meta.actions. +func recordActionNames(body map[string]any) []string { + meta, _ := body["meta"].(map[string]any) + actions, _ := meta["actions"].([]any) + out := []string{} + for _, action := range actions { + if entry, ok := action.(map[string]any); ok { + name, _ := entry["name"].(string) + out = append(out, name) + } + } + return out +} + +const usersPath = "/summercms/cabana/users" + +func TestBackendScreensPostgresSmoke(t *testing.T) { + env := newScreensEnv(t) + root, rootToken := env.admin(t, "screens-root", true, "") + dev, devToken := env.admin(t, "screens-dev", false, "") + + list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil) + ids := listIDs(t, list) + if !ids[root.ID] || !ids[dev.ID] { + t.Fatalf("superuser list ids = %v, want %d and %d", ids, root.ID, dev.ID) + } + + show := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, dev.ID), rootToken, nil) + if names := recordActionNames(show); len(names) == 0 || names[len(names)-1] != "acme_ping" { + t.Fatalf("meta.actions = %v, want acme_ping last", names) + } + result := env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/acme_ping", usersPath, dev.ID), rootToken, map[string]any{}) + if data, _ := result["data"].(map[string]any); data["message"] != "Pinged" { + t.Fatalf("action result = %v", result) + } + runs, withTx := env.spy.runs() + if len(runs) != 1 || runs[0] != uint64(dev.ID) || !withTx[0] { + t.Fatalf("acme_ping runs = %v tx = %v", runs, withTx) + } + + settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil) + found := false + entries, _ := settings["data"].([]any) + for _, entry := range entries { + item, _ := entry.(map[string]any) + if item["code"] == "administrators" { + found = item["controller"] == cabana.AdministratorsControllerID && item["category"] == "System" && item["label"] == "Administrators" + } + } + if !found { + t.Fatalf("superuser settings = %v", entries) + } + + if rec := env.call(t, http.MethodGet, usersPath, devToken, nil); rec.Code != http.StatusForbidden { + t.Fatalf("developer list status=%d body=%s", rec.Code, rec.Body.String()) + } + settings = env.expect(t, http.StatusOK, http.MethodGet, "/settings", devToken, nil) + if strings.Contains(fmt.Sprint(settings["data"]), "administrators") { + t.Fatalf("developer settings list administrators: %v", settings["data"]) + } +} diff --git a/modules/cabana/backend_screens_test.go b/modules/cabana/backend_screens_test.go new file mode 100644 index 00000000..01328910 --- /dev/null +++ b/modules/cabana/backend_screens_test.go @@ -0,0 +1,175 @@ +package cabana + +import ( + "context" + "io/fs" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// screensExtender owns one list controller (so the admin is active) and adds +// record actions to other controllers through pact.HasAdminActionExtensions. +type screensExtender struct { + extensions []pact.AdminActionExtension +} + +type screensExtenderController struct{} + +func (screensExtenderController) ID() string { return "acme.ext.items" } +func (screensExtenderController) ModelName() string { return "Item" } +func (screensExtenderController) ConfigDir() string { return "controllers/items" } +func (screensExtenderController) RequiredPermissions() []string { return []string{"acme.ext.access"} } + +func (screensExtender) ID() string { return "acme.ext" } +func (screensExtender) Requires() []string { return nil } +func (screensExtender) Register(*backpack.App) error { return nil } +func (screensExtender) Boot(*backpack.App) error { return nil } +func (screensExtender) AdminControllers() []pact.AdminController { + return []pact.AdminController{screensExtenderController{}} +} +func (screensExtender) AdminFS() fs.FS { + return fstest.MapFS{ + "controllers/items/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/ext/models/item/columns.yaml\nmodelClass: Item\nrecordsPerPage: 20\n")}, + "models/item/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")}, + } +} +func (screensExtender) Permissions() []pact.Permission { + return []pact.Permission{ + {Code: "acme.ext.access", Roles: []string{"developer"}}, + {Code: "acme.ext.ping"}, + } +} +func (p screensExtender) AdminActionExtensions() []pact.AdminActionExtension { return p.extensions } + +func pingAction() pact.AdminRecordAction { + return pact.AdminRecordAction{ + Name: "acme_ping", Label: "Ping", Permissions: []string{"acme.ext.ping"}, + Run: func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + return pact.AdminRecordActionResult{}, nil + }, + } +} + +// compileWithScreens runs the compile steps of Activate that need no config +// or database on plugins plus the admin's own screens. +func compileWithScreens(t *testing.T, plugins []party.Plugin) (*Registry, error) { + t.Helper() + extended, screens := withBackendScreens(nil, plugins) + if len(extended) != len(plugins)+1 || extended[len(extended)-1] != party.Plugin(screens) { + t.Fatalf("withBackendScreens = %v", extended) + } + items, err := collectControllers(extended) + if err != nil { + return nil, err + } + if err := checkReservedSegments(items); err != nil { + return nil, err + } + types, err := compileFieldTypes(extended) + if err != nil { + return nil, err + } + reg, err := compileRegistryWithTypes(items, types) + if err != nil { + return nil, err + } + if err := compileActionExtensions(reg, extended); err != nil { + return nil, err + } + if err := compileContributions(reg, extended); err != nil { + return nil, err + } + screens.reg = reg + return reg, nil +} + +func TestBackendScreensSmoke(t *testing.T) { + extender := screensExtender{extensions: []pact.AdminActionExtension{{ + Controller: AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{pingAction()}, + }}} + plugins := []party.Plugin{extender} + reg, err := compileWithScreens(t, plugins) + if err != nil { + t.Fatal(err) + } + if len(plugins) != 1 { + t.Fatalf("the caller's plugin slice changed: %v", plugins) + } + + cc, ok := reg.Get(AdministratorsControllerID) + if !ok || cc.Form == nil || cc.Form.preview == nil { + t.Fatalf("administrators controller = %#v", cc) + } + names := cc.Form.recordActions + if len(names) == 0 || names[len(names)-1] != "acme_ping" { + t.Fatalf("administrators record actions = %v, want acme_ping last", names) + } + if _, ok := cc.RecordActions["acme_ping"]; !ok || cc.contributed["acme_ping"] != "acme.ext" { + t.Fatalf("RecordActions = %v contributed = %v", cc.RecordActions, cc.contributed) + } + if got := recordActionOfName(cc, "acme_ping"); !got { + t.Fatal("recordActionOf does not resolve the contributed action") + } + + for _, code := range []string{PermissionManageUsers, PermissionManageRoles} { + if _, ok := reg.permissions[code]; !ok { + t.Fatalf("catalog lacks %s", code) + } + for _, role := range []string{"developer", "publisher"} { + if reg.rolePermissions(role)[code] { + t.Fatalf("role %s is granted %s in code", role, code) + } + } + } + if order := reg.permissionOrder; len(order) != len(reg.permissions) || order[0] != "acme.ext.access" { + t.Fatalf("permissionOrder = %v", order) + } + + ctx := context.Background() + _, settings := reg.Metadata(ctx, &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}, nil) + found := false + for _, entry := range settings { + if entry.Code == "administrators" { + found = entry.Controller == AdministratorsControllerID && entry.Category == "backend::lang.settings.category_system" && entry.Order == 100 + } + } + if !found { + t.Fatalf("superuser settings = %+v, want administrators", settings) + } + _, settings = reg.Metadata(ctx, &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.ext.access": true}}, nil) + for _, entry := range settings { + if entry.Code == "administrators" { + t.Fatalf("an administrator without %s sees %+v", PermissionManageUsers, entry) + } + } + _, settings = reg.Metadata(ctx, &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{PermissionManageUsers: true}}, nil) + if len(settings) != 1 || settings[0].Code != "administrators" { + t.Fatalf("holder of %s sees %+v", PermissionManageUsers, settings) + } + + missing := screensExtender{extensions: []pact.AdminActionExtension{{ + Controller: "acme.missing.things", + RecordActions: []pact.AdminRecordAction{pingAction()}, + }}} + if _, err := compileWithScreens(t, []party.Plugin{missing}); err == nil || !strings.Contains(err.Error(), "acme.missing.things") { + t.Fatalf("unknown controller error = %v", err) + } + + routes, err := Activate(nil, []party.Plugin{contributionPlugin{id: "acme.plain", permissions: []pact.Permission{{Code: "acme.plain.use"}}}}) + if routes != nil || err != nil { + t.Fatalf("Activate without admin controllers = %v, %v", routes, err) + } +} + +// recordActionOfName reports whether the record action route resolves name. +func recordActionOfName(cc *CompiledController, name string) bool { + _, ok := recordActionOf(cc, name) + return ok +} diff --git a/modules/cabana/backend_users_admin.go b/modules/cabana/backend_users_admin.go new file mode 100644 index 00000000..8ff211c0 --- /dev/null +++ b/modules/cabana/backend_users_admin.go @@ -0,0 +1,70 @@ +package cabana + +import ( + "context" + "errors" + "strings" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// usersAdmin is the Administrators screen (summercms.cabana.users), the +// WinterCMS Backend\Controllers\Users controller over backend_users. Every +// route needs backend.manage_users. screens gives access to the activating +// app and the compiled registry at request time. +type usersAdmin struct { + screens *backendScreens +} + +var ( + _ pact.AdminController = usersAdmin{} + _ pact.AdminPermissioned = usersAdmin{} + _ pact.AdminRecordSource = usersAdmin{} + _ pact.FormRecordTitle = usersAdmin{} + _ FieldRelationProvider = usersAdmin{} +) + +func (usersAdmin) ID() string { return AdministratorsControllerID } + +func (usersAdmin) ModelName() string { return BackendUserMorphType } + +func (usersAdmin) ConfigDir() string { return "controllers/users" } + +func (usersAdmin) RequiredPermissions() []string { return []string{PermissionManageUsers} } + +func (usersAdmin) NewRecord() any { return &BackendUser{} } + +// application is the activating app, or nil. +func (c usersAdmin) application() *backpack.App { return c.screens.application() } + +// registry is the compiled registry, or nil before compilation finished. +func (c usersAdmin) registry() *Registry { return c.screens.registry() } + +// FormRecordTitle names an administrator "First Last" when either name is +// set, otherwise by the login. +func (usersAdmin) FormRecordTitle(_ context.Context, _ *gorm.DB, record any) (string, error) { + user, ok := record.(*BackendUser) + if !ok || user == nil { + return "", errors.New("cabana: administrators: record is not a backend user") + } + if name := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName)); name != "" { + return name, nil + } + return user.Login, nil +} + +// AdminFieldRelations binds the role picker. role_id is a protected column, +// so the contract opts in to writing it through this one field; the +// submitted id is still checked against the options and the role locks. +func (usersAdmin) AdminFieldRelations() []FieldRelationContract { + return []FieldRelationContract{{ + Field: "role", + Kind: "belongsTo", + NewRelated: func() any { return &BackendUserRole{} }, + ForeignKey: "role_id", + LabelColumn: "name", + WritableForeignKey: true, + }} +} diff --git a/modules/cabana/backendscreens/controllers/users/config_form.yaml b/modules/cabana/backendscreens/controllers/users/config_form.yaml new file mode 100644 index 00000000..b6f9b8ef --- /dev/null +++ b/modules/cabana/backendscreens/controllers/users/config_form.yaml @@ -0,0 +1,16 @@ +name: backend::lang.user.name +form: ~/plugins/summercms/cabana/models/user/fields.yaml +modelClass: Backend\Models\User +defaultRedirect: summercms/cabana/users + +update: + redirect: summercms/cabana/users/update/:id + redirectClose: summercms/cabana/users/preview/:id + +preview: {} + +messages: + update: backend::lang.messages.form.update + saved: backend::lang.messages.form.saved + edit: backend::lang.user.edit + preview: backend::lang.user.preview diff --git a/modules/cabana/backendscreens/controllers/users/config_list.yaml b/modules/cabana/backendscreens/controllers/users/config_list.yaml new file mode 100644 index 00000000..2c1029cc --- /dev/null +++ b/modules/cabana/backendscreens/controllers/users/config_list.yaml @@ -0,0 +1,11 @@ +list: ~/plugins/summercms/cabana/models/user/columns.yaml +modelClass: Backend\Models\User +title: backend::lang.user.list_title +recordUrl: summercms/cabana/users/preview/:id +noRecordsMessage: backend::lang.user.list_empty +recordsPerPage: 20 +showSetup: true + +toolbar: + search: + prompt: backend::lang.list.search_prompt diff --git a/modules/cabana/backendscreens/models/user/columns.yaml b/modules/cabana/backendscreens/models/user/columns.yaml new file mode 100644 index 00000000..3baa1fbf --- /dev/null +++ b/modules/cabana/backendscreens/models/user/columns.yaml @@ -0,0 +1,34 @@ +columns: + login: + label: backend::lang.user.login + searchable: true + sortable: true + first_name: + label: backend::lang.user.first_name + searchable: true + sortable: true + last_name: + label: backend::lang.user.last_name + searchable: true + sortable: true + email: + label: backend::lang.user.email + searchable: true + sortable: true + role: + label: backend::lang.user.role_field + relation: role + select: name + sortable: true + last_login: + label: backend::lang.user.last_login + type: datetime + sortable: true + is_superuser: + label: backend::lang.user.superuser + type: switch + created_at: + label: backend::lang.user.created_at + type: datetime + invisible: true + sortable: true diff --git a/modules/cabana/backendscreens/models/user/fields.yaml b/modules/cabana/backendscreens/models/user/fields.yaml new file mode 100644 index 00000000..0c97b450 --- /dev/null +++ b/modules/cabana/backendscreens/models/user/fields.yaml @@ -0,0 +1,35 @@ +fields: + login: + label: backend::lang.user.login + type: text + span: left + tab: backend::lang.user.account + email: + label: backend::lang.user.email + type: text + span: right + tab: backend::lang.user.account + first_name: + label: backend::lang.user.first_name + type: text + span: left + tab: backend::lang.user.account + last_name: + label: backend::lang.user.last_name + type: text + span: right + tab: backend::lang.user.account + role: + label: backend::lang.user.role_field + comment: backend::lang.user.role_comment + type: relation + nameFrom: name + emptyOption: backend::lang.user.role_none + span: full + tab: backend::lang.user.account + last_login: + label: backend::lang.user.last_login + type: datepicker + span: left + tab: backend::lang.user.account + context: preview diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index cb10cc08..38719acc 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -27,6 +27,21 @@ type BackendUserRole struct { func (BackendUserRole) TableName() string { return "backend_user_roles" } +// Fillable lists the role columns the admin's Roles screen writes from a +// form: name, code and description. permissions and is_system are never +// filled (the permission editor stores the grants, and is_system only comes +// from migrations). +func (BackendUserRole) Fillable() []string { return []string{"name", "code", "description"} } + +// Rules are the role's admin save rules: a name unique among roles and a +// code. The code's uniqueness is checked by the Roles screen itself. +func (BackendUserRole) Rules() map[string]string { + return map[string]string{ + "name": "required|max:255|unique:backend_user_roles", + "code": "required|max:255", + } +} + // BackendUser is the Winter backend_users row. It is not a frontend user. // Nullable Winter columns are mapped so a copied row round-trips without a // schema transform. TokensValidAfter is the admin reset cutoff, not a Winter column. @@ -59,6 +74,24 @@ type BackendUser struct { func (BackendUser) TableName() string { return "backend_users" } +// Fillable lists the columns the admin's Administrators screen writes from a +// form: login, email, first and last name. Role, permissions, the superuser +// flag, activation and the password are protected and written only by the +// screen's own checks. +func (BackendUser) Fillable() []string { + return []string{"login", "email", "first_name", "last_name"} +} + +// Rules are the administrator's admin save rules. The uniqueness of login +// and email, deleted administrators included and ignoring case, is checked +// by the Administrators screen itself. +func (BackendUser) Rules() map[string]string { + return map[string]string{ + "login": "required|max:255", + "email": "required|email|max:255", + } +} + const ( // BackendUserMorphType is the system_files attachment_type of a backend // user's files: WinterCMS's class string, so avatar rows copied from @@ -114,6 +147,9 @@ type CompiledController struct { // RecordActions are the controller's pact.HasAdminRecordActions entries // keyed by name, a third namespace with the same reserved names. RecordActions map[string]pact.AdminRecordAction + // contributed maps the name of each record action another plugin added + // through pact.HasAdminActionExtensions to that plugin's ID. + contributed map[string]string // scripts and styles are the controller's declared plugin JS and CSS, // in declared order. @@ -147,9 +183,12 @@ type CompiledController struct { type Registry struct { byID map[string]*CompiledController permissions map[string]pact.Permission - roleGrants map[string]map[string]bool - navigation []pact.NavigationItem - settings map[string]*CompiledSetting + // permissionOrder holds the permission codes in registration order + // (plugin activation order, then each plugin's declared order). + permissionOrder []string + roleGrants map[string]map[string]bool + navigation []pact.NavigationItem + settings map[string]*CompiledSetting // assets are every controller's declared plugin files keyed by URL tail // (vendor/plugin/); the asset route serves only these. assets map[string]*pluginAsset diff --git a/modules/cabana/example_action_extension_test.go b/modules/cabana/example_action_extension_test.go new file mode 100644 index 00000000..2d624baf --- /dev/null +++ b/modules/cabana/example_action_extension_test.go @@ -0,0 +1,61 @@ +package cabana_test + +import ( + "context" + "errors" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// ProfileLink is a row of the acme.profiles plugin that links a frontend +// profile to a backend administrator. +type ProfileLink struct { + ID uint `gorm:"column:id;primaryKey"` + BackendUserID uint `gorm:"column:backend_user_id"` +} + +func (ProfileLink) TableName() string { return "acme_profiles_links" } + +// ProfilesPlugin adds a record action to the admin's Administrators screen, +// a controller it does not own. +type ProfilesPlugin struct{} + +var _ pact.HasAdminActionExtensions = ProfilesPlugin{} + +// AdminActionExtensions adds an action to the Administrators screen. +func (ProfilesPlugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{{ + Name: "acme_unlink_profile", + Label: "acme.profiles::lang.unlink", + Confirm: "acme.profiles::lang.unlink_confirm", + Permissions: []string{"acme.profiles.manage_links"}, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) // the action's row-locked transaction + if !ok { + return pact.AdminRecordActionResult{}, errors.New("acme.profiles: no transaction") + } + err := tx.Where("backend_user_id = ?", in.RecordID).Delete(&ProfileLink{}).Error + return pact.AdminRecordActionResult{Message: "acme.profiles::lang.unlinked"}, err + }, + }}, + }} +} + +func TestExampleActionExtension(t *testing.T) { + extensions := ProfilesPlugin{}.AdminActionExtensions() + if len(extensions) != 1 || extensions[0].Controller != "summercms.cabana.users" || len(extensions[0].RecordActions) != 1 { + t.Fatalf("extensions = %+v", extensions) + } + action := extensions[0].RecordActions[0] + if action.Name != "acme_unlink_profile" || action.Run == nil { + t.Fatalf("action = %+v", action) + } + // Outside the record action route there is no transaction. + if _, err := action.Run(context.Background(), pact.AdminRecordActionInput{RecordID: 1}); err == nil { + t.Fatal("Run without a transaction succeeded") + } +} diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 4e65cddf..845215ba 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -71,6 +71,10 @@ func (s *service) apiBase() string { // Activate compiles admin controllers and, when any exist, requires // admin.jwt.secret. No controllers means no admin routes and no secret check. +// When some plugin registers admin controllers, the admin's own +// Administrators and Roles screens (an internal pseudo-plugin with ID +// summercms.cabana) are compiled with them, and the record actions plugins +// add to any controller through pact.HasAdminActionExtensions are merged in. func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { items, err := collectControllers(plugins) if err != nil { @@ -79,6 +83,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if len(items) == 0 { return nil, nil } + // The admin is active: its own screens join the plugins for every later + // step. plugins is the caller's slice and is never modified. + plugins, screens := withBackendScreens(app, plugins) + if items, err = collectControllers(plugins); err != nil { + return nil, err + } if err := checkReservedSegments(items); err != nil { return nil, err } @@ -122,9 +132,15 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err := checkDeferredModels(reg, plugins, gdb); err != nil { return nil, err } + // Contributed record actions are merged before compileContributions, + // which validates every record action's permissions. + if err := compileActionExtensions(reg, plugins); err != nil { + return nil, err + } if err := compileContributions(reg, plugins); err != nil { return nil, err } + screens.reg = reg if err := compileTokenScopes(reg, plugins); err != nil { return nil, err } diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index a077d7fd..78c41e73 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -233,6 +233,7 @@ func compilePermissionCatalog(reg *Registry, plugins []party.Plugin) error { return fmt.Errorf("cabana: duplicate permission %s", permission.Code) } reg.permissions[permission.Code] = permission + reg.permissionOrder = append(reg.permissionOrder, permission.Code) for _, role := range permission.Roles { role = strings.TrimSpace(role) if role == "" { @@ -270,6 +271,7 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error { return fmt.Errorf("cabana: registry is nil") } reg.permissions = map[string]pact.Permission{} + reg.permissionOrder = nil reg.roleGrants = map[string]map[string]bool{} reg.settings = map[string]*CompiledSetting{} seenNavigation := map[string]struct{}{} diff --git a/modules/pact/README.md b/modules/pact/README.md index 9d66befa..e4950a01 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -27,7 +27,7 @@ Use cases: - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed). - Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token. - Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`. -- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders). +- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), `pact.HasAdminActionExtensions` with `pact.AdminActionExtension` (a plugin adds record actions to any admin controller by controller ID, one it does not own included; they are offered on that controller's preview screen after the owner's), and `pact.AdminPartialData` (the curated view model a partial template renders). - Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices. - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree. @@ -137,6 +137,8 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope. | | `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. | | `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. | +| `pact.AdminActionExtension` | Record actions a plugin adds to one admin controller by its `Controller` ID; offered on the target's preview screen after the owner's actions, each checked against the target's `RequiredPermissions` and its own `Permissions`. | +| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block or a duplicate name stops the boot. | | `pact.ReorderAfterSave` | Optional controller hook `ReorderAfterSave(ctx, partition)`: runs after a reorder save wrote the new order, inside the same transaction (`cabana.TxFromContext` works); an error rolls the reorder back. | | `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | | `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 8516593b..a0967294 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -375,6 +375,38 @@ type HasAdminRecordActions interface { AdminRecordActions() []AdminRecordAction } +// AdminActionExtension adds actions to an admin controller that another +// plugin owns, or that the plugin itself owns. Controller is the controller +// ID (vendor.plugin.controller) of any activated plugin's admin controller, +// the admin framework's own controllers included. +// +// RecordActions are offered on the target controller's preview screen after +// the owner's record actions, in plugin activation order. Each one keeps its +// own Permissions, Applies, Confirm and Danger and runs exactly like the +// owner's record actions: the framework loads and row-locks the record +// through the target's FormExtendQuery scope inside the action's transaction +// and hands it to Run, which reads that transaction with +// cabana.TxFromContext. An administrator needs the target controller's +// RequiredPermissions and the action's own Permissions. A record action +// carries no input beyond the record. +// +// The admin compiles extensions at boot, only when it is active (some +// plugin registers admin controllers). An unknown controller ID, a target +// without a preview block, a name the owner or an earlier extension already +// registers on that controller, a reserved name (create, delete), a name that +// is not an identifier, a missing Run or an empty Label stops the boot. Only +// record actions are carried for now; the struct leaves room for more kinds. +type AdminActionExtension struct { + Controller string + RecordActions []AdminRecordAction +} + +// HasAdminActionExtensions is implemented by plugins (not admin controllers) +// that add actions to admin controllers by controller ID. +type HasAdminActionExtensions interface { + AdminActionExtensions() []AdminActionExtension +} + // AdminPartialData supplies the view model a controller partial template // renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name // is the partial name; record is the scoped record for a form partial on an diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index f44d55d3..b9baf688 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -131,6 +131,7 @@ relation: pending_note: Changes in this section are saved together with the record. settings: title: Settings + category_system: "System" empty: No settings are available. saved: Settings saved back: Back to settings @@ -422,3 +423,105 @@ reorder: moved: ":name moved to position :position of :total." nested: ":name is now inside :parent." unnested: ":name moved out to level :level." +user: + name: "Administrator" + list_title: "Administrators" + new: "New administrator" + edit: "Edit administrator" + preview: "Administrator details" + list_empty: "No administrators in this view. Clear the search and filters, or create an administrator." + menu_label: "Administrators" + menu_description: "Manage administrator accounts, their roles and permissions." + login: "Login" + first_name: "First name" + last_name: "Last name" + email: "Email" + role_field: "Role" + last_login: "Last sign-in" + superuser: "Superuser" + created_at: "Created" + filter_activated: "Active" + filter_deleted: "Deleted" + filter_deleted_hide: "Hide deleted" + filter_deleted_only: "Only deleted" + state_deleted: "Deleted" + state_deactivated: "Deactivated" + delete_selected_confirm: + one: "Delete :count administrator? They are signed out at once. Deleted accounts can be restored." + other: "Delete :count administrators? They are signed out at once. Deleted accounts can be restored." + account: "Account" + permissions: "Permissions" + password: "Password" + password_comment: "At least 4 characters. When editing, leave blank to keep the current password." + password_confirmation: "Confirm password" + role_comment: "The role sets this administrator's permissions. Single permissions can be adjusted on the Permissions tab." + role_none: "No role" + superuser_comment: "Grants every permission in the admin, including managing other administrators. Permission overrides below are not used while this is on." + permission_overrides: "Permission overrides" + permission_overrides_comment: "Inherit follows the role. Allow and Deny apply to this administrator only." + delete_confirm: "Delete this administrator? They are signed out at once. The account can be restored from the list." + activate: "Activate" + activate_confirm: "Activate this administrator? They can sign in again, and their existing API tokens work again." + activate_selected_confirm: "Activate the selected administrators? They can sign in again, and their existing API tokens work again." + activated: "Administrator activated" + activated_selected: "Selected administrators activated" + deactivate: "Deactivate" + deactivate_confirm: "Deactivate this administrator? They are signed out at once and cannot sign in until activated again." + deactivate_selected_confirm: "Deactivate the selected administrators? They are signed out at once and cannot sign in until activated again." + deactivated: "Administrator deactivated" + deactivated_selected: "Selected administrators deactivated" + restore: "Restore" + restore_confirm: "Restore this administrator? If the account is active, they can sign in again and their existing API tokens work again." + restore_selected_confirm: "Restore the selected administrators? Active accounts can sign in again, and their existing API tokens work again." + restored: "Administrator restored" + restored_selected: "Selected administrators restored" + trashed_hint_title: "This account has been deleted" + trashed_hint_desc: "It cannot be used to sign in. Use Restore to bring it back." + deactivated_hint_title: "This account is deactivated" + deactivated_hint_desc: "It cannot be used to sign in. Use Activate to allow sign-in again." + self_hint_title: "This is your account" + self_hint_desc: "You can change your name, login and email here. Your role, permissions, superuser access and activation can only be changed by another administrator. Change your password in My account." + superuser_forbidden: "Only a superuser can grant, remove or change superuser access." + role_ceiling: "You can only assign roles whose permissions you have yourself." + self_privileged: "You cannot change your own role, permissions, superuser access or activation. Ask another administrator." + self_delete: "You cannot delete your own account." + self_deactivate: "You cannot deactivate your own account." + last_superuser: "This is the last active superuser, so it cannot be deactivated, deleted or lose superuser access. Make another administrator a superuser first." + outranked: "This administrator has permissions you do not have, so you cannot change, deactivate or delete their account." + own_password: "Change your own password in My account, where your current password is checked." + login_taken: "This login is already used by another administrator." + login_taken_deleted: "This login belongs to a deleted administrator. Restore that account instead of creating a new one." + email_taken: "This email is already used by another administrator." + email_taken_deleted: "This email belongs to a deleted administrator. Restore that account instead of creating a new one." + role: + name: "Role" + list_title: "Roles" + new: "New role" + edit: "Edit role" + list_empty: "No roles in this view. Clear the search, or create a role." + menu_label: "Roles" + menu_description: "Define roles and the permissions they grant." + name_field: "Name" + name_comment: "Shown in the role picker on the administrator form." + code_field: "Code" + code_comment: "A unique identifier used by plugins and the API. The code of a system role cannot be changed." + description_field: "Description" + is_system: "System role" + permissions_field: "Granted permissions" + permissions_comment: "Every administrator with this role gets the checked permissions." + delete_confirm: "Delete this role? This cannot be undone. A role that is still assigned to administrators cannot be deleted." + delete_selected_confirm: + one: "Delete :count role? This cannot be undone." + other: "Delete :count roles? This cannot be undone." + delete_has_users: + one: "The role “:name” is assigned to :count administrator. Give them another role first. Deleted accounts count too." + other: "The role “:name” is assigned to :count administrators. Give them another role first. Deleted accounts count too." + 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." +permissions: + tab_system: "System" + manage_users: "Manage administrators" + manage_users_comment: "Create, edit, deactivate and delete administrator accounts." + manage_roles: "Manage roles" + manage_roles_comment: "Create and edit roles and the permissions they grant." diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index 9e5afddf..95cc2f99 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -141,6 +141,7 @@ relation: pending_note: Zmiany w tej sekcji zostaną zapisane razem z rekordem. settings: title: Ustawienia + category_system: "System" empty: Brak dostępnych ustawień. saved: Zapisano ustawienia back: Wróć do ustawień @@ -450,3 +451,111 @@ reorder: moved: "Przeniesiono :name na pozycję :position z :total." nested: ":name jest teraz wewnątrz :parent." unnested: "Przeniesiono :name na poziom :level." +user: + name: "Administrator" + list_title: "Administratorzy" + new: "Nowy administrator" + edit: "Edytuj administratora" + preview: "Szczegóły administratora" + list_empty: "Brak administratorów w tym widoku. Wyczyść wyszukiwanie i filtry lub utwórz administratora." + menu_label: "Administratorzy" + menu_description: "Zarządzaj kontami administratorów, ich rolami i uprawnieniami." + login: "Login" + first_name: "Imię" + last_name: "Nazwisko" + email: "E-mail" + role_field: "Rola" + last_login: "Ostatnie logowanie" + superuser: "Superużytkownik" + created_at: "Utworzono" + filter_activated: "Aktywny" + filter_deleted: "Usunięci" + filter_deleted_hide: "Ukryj usuniętych" + filter_deleted_only: "Tylko usunięci" + state_deleted: "Usunięty" + state_deactivated: "Dezaktywowany" + delete_selected_confirm: + one: "Usunąć :count administratora? Zostanie od razu wylogowany. Usunięte konta można przywrócić." + few: "Usunąć :count administratorów? Zostaną od razu wylogowani. Usunięte konta można przywrócić." + many: "Usunąć :count administratorów? Zostaną od razu wylogowani. Usunięte konta można przywrócić." + other: "Usunąć :count administratora? Zostaną od razu wylogowani. Usunięte konta można przywrócić." + account: "Konto" + permissions: "Uprawnienia" + password: "Hasło" + password_comment: "Co najmniej 4 znaki. Przy edycji zostaw puste, aby zachować obecne hasło." + password_confirmation: "Potwierdź hasło" + role_comment: "Rola określa uprawnienia administratora. Pojedyncze uprawnienia można zmienić w zakładce Uprawnienia." + role_none: "Brak roli" + superuser_comment: "Daje wszystkie uprawnienia w panelu, w tym zarządzanie innymi administratorami. Gdy jest włączone, nadpisania uprawnień poniżej nie są używane." + permission_overrides: "Nadpisania uprawnień" + permission_overrides_comment: "Dziedzicz oznacza ustawienie z roli. Zezwól i Odmów dotyczą tylko tego administratora." + delete_confirm: "Usunąć tego administratora? Zostanie od razu wylogowany. Konto można przywrócić z listy." + activate: "Aktywuj" + activate_confirm: "Aktywować tego administratora? Będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają." + activate_selected_confirm: "Aktywować zaznaczonych administratorów? Będą mogli się zalogować, a ich dotychczasowe tokeny API znów zadziałają." + activated: "Aktywowano administratora" + activated_selected: "Aktywowano zaznaczonych administratorów" + deactivate: "Dezaktywuj" + deactivate_confirm: "Dezaktywować tego administratora? Zostanie od razu wylogowany i nie zaloguje się, dopóki konto nie zostanie ponownie aktywowane." + deactivate_selected_confirm: "Dezaktywować zaznaczonych administratorów? Zostaną od razu wylogowani i nie zalogują się, dopóki ich konta nie zostaną ponownie aktywowane." + deactivated: "Dezaktywowano administratora" + deactivated_selected: "Dezaktywowano zaznaczonych administratorów" + restore: "Przywróć" + restore_confirm: "Przywrócić tego administratora? Jeśli konto jest aktywne, będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają." + restore_selected_confirm: "Przywrócić zaznaczonych administratorów? Aktywne konta będą mogły się zalogować, a ich dotychczasowe tokeny API znów zadziałają." + restored: "Przywrócono administratora" + restored_selected: "Przywrócono zaznaczonych administratorów" + trashed_hint_title: "To konto zostało usunięte" + trashed_hint_desc: "Nie można się na nie zalogować. Użyj „Przywróć”, aby je odzyskać." + deactivated_hint_title: "To konto jest dezaktywowane" + deactivated_hint_desc: "Nie można się na nie zalogować. Użyj „Aktywuj”, aby ponownie zezwolić na logowanie." + self_hint_title: "To Twoje konto" + self_hint_desc: "Tutaj zmienisz imię, nazwisko, login i e-mail. Rolę, uprawnienia, dostęp superużytkownika i aktywację może zmienić tylko inny administrator. Hasło zmień w sekcji Moje konto." + superuser_forbidden: "Tylko superużytkownik może nadawać, odbierać lub zmieniać dostęp superużytkownika." + role_ceiling: "Możesz przypisywać tylko role, których uprawnienia sam posiadasz." + self_privileged: "Nie możesz zmieniać własnej roli, uprawnień, dostępu superużytkownika ani aktywacji. Poproś innego administratora." + self_delete: "Nie możesz usunąć własnego konta." + self_deactivate: "Nie możesz dezaktywować własnego konta." + last_superuser: "To ostatni aktywny superużytkownik, więc nie można go dezaktywować, usunąć ani odebrać mu dostępu superużytkownika. Najpierw nadaj ten dostęp innemu administratorowi." + outranked: "Ten administrator ma uprawnienia, których nie masz, więc nie możesz zmieniać, dezaktywować ani usuwać jego konta." + own_password: "Własne hasło zmień w sekcji Moje konto, która sprawdza obecne hasło." + login_taken: "Ten login jest już używany przez innego administratora." + login_taken_deleted: "Ten login należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe." + email_taken: "Ten adres e-mail jest już używany przez innego administratora." + email_taken_deleted: "Ten adres e-mail należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe." + role: + name: "Rola" + list_title: "Role" + new: "Nowa rola" + edit: "Edytuj rolę" + list_empty: "Brak ról w tym widoku. Wyczyść wyszukiwanie lub utwórz rolę." + menu_label: "Role" + menu_description: "Określ role i uprawnienia, które nadają." + name_field: "Nazwa" + name_comment: "Wyświetlana na liście ról w formularzu administratora." + code_field: "Kod" + code_comment: "Unikalny identyfikator używany przez wtyczki i API. Kodu roli systemowej nie można zmienić." + description_field: "Opis" + is_system: "Rola systemowa" + permissions_field: "Nadane uprawnienia" + permissions_comment: "Każdy administrator z tą rolą otrzymuje zaznaczone uprawnienia." + delete_confirm: "Usunąć tę rolę? Tej operacji nie można cofnąć. Roli przypisanej do administratorów nie można usunąć." + delete_selected_confirm: + one: "Usunąć :count rolę? Tej operacji nie można cofnąć." + few: "Usunąć :count role? Tej operacji nie można cofnąć." + many: "Usunąć :count ról? Tej operacji nie można cofnąć." + other: "Usunąć :count roli? Tej operacji nie można cofnąć." + delete_has_users: + one: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel mu inną rolę. Liczą się też usunięte konta." + few: "Rola „:name” jest przypisana do :count administratorów. Najpierw przydziel im inną rolę. Liczą się też usunięte konta." + many: "Rola „:name” jest przypisana do :count administratorów. Najpierw przydziel im inną rolę. Liczą się też usunięte konta." + other: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel inną rolę. Liczą się też usunięte konta." + 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ę." +permissions: + tab_system: "System" + manage_users: "Zarządzanie administratorami" + manage_users_comment: "Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów." + manage_roles: "Zarządzanie rolami" + manage_roles_comment: "Tworzenie i edycja ról oraz uprawnień, które nadają."