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:
Jakub Zych
2026-10-22 10:59:23 +02:00
parent 88ec4df2b3
commit 7340241f22
19 changed files with 1239 additions and 5 deletions

View File

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

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

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

View 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"])
}
}

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

View 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,
}}
}

View File

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

View File

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

View 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

View 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

View File

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

View 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")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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