feat(19.6.1-01): Administrators screen and cross-controller record actions
- pact.HasAdminActionExtensions / AdminActionExtension: plugins add record actions to any admin controller by ID, merged at boot before permission validation (unknown target, no preview, duplicate name stop the boot) - internal summercms.cabana pseudo-plugin, appended only when some plugin registers admin controllers: Administrators list and preview, settings link under System, backend.manage_users and backend.manage_roles with no role grants - backend::lang user, user.role and permissions copy (en, pl) - smoke tests: compile-time seam and settings, Postgres list/preview/action
This commit is contained in:
@@ -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). |
|
||||
|
||||
97
modules/cabana/action_extensions.go
Normal file
97
modules/cabana/action_extensions.go
Normal file
@@ -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
|
||||
}
|
||||
129
modules/cabana/backend_screens.go
Normal file
129
modules/cabana/backend_screens.go
Normal file
@@ -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
|
||||
}
|
||||
260
modules/cabana/backend_screens_smoke_test.go
Normal file
260
modules/cabana/backend_screens_smoke_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
}
|
||||
175
modules/cabana/backend_screens_test.go
Normal file
175
modules/cabana/backend_screens_test.go
Normal file
@@ -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
|
||||
}
|
||||
70
modules/cabana/backend_users_admin.go
Normal file
70
modules/cabana/backend_users_admin.go
Normal file
@@ -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,
|
||||
}}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
34
modules/cabana/backendscreens/models/user/columns.yaml
Normal file
34
modules/cabana/backendscreens/models/user/columns.yaml
Normal file
@@ -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
|
||||
35
modules/cabana/backendscreens/models/user/fields.yaml
Normal file
35
modules/cabana/backendscreens/models/user/fields.yaml
Normal file
@@ -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
|
||||
@@ -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/<path after assets/>); the asset route serves only these.
|
||||
assets map[string]*pluginAsset
|
||||
|
||||
61
modules/cabana/example_action_extension_test.go
Normal file
61
modules/cabana/example_action_extension_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}{}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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ą."
|
||||
|
||||
Reference in New Issue
Block a user