Files
summercms/docs/backend/admin-controllers.md
Jakub Zych a1c6bb1ce6 feat(12.1-02): password and form-only fields, rules per operation and preset
- pact.FormVirtualFields lists form fields that are not model columns: never
  bound, filled or projected; their values reach the Form hooks through
  cabana.VirtualFieldsFromContext when the field's context allows the operation
- type: password is a masked field that must be listed as virtual
- pact.FormRules supplies the rule set per operation and replaces the model's
  Rules() for admin saves; a rule on a virtual field sees the submitted value
- preset on a text field follows another text field on the create form
- SPA: PasswordField, preset handling in FormView, empty password left out of
  an update
- README, docs, OpenAPI document, TS types and dist updated
2026-10-05 10:35:08 +02:00

25 KiB

title, description, section, order
title description section order
Admin controllers Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records. backend 10

Admin controllers

A WinterCMS backend controller extends Backend\Classes\Controller, implements the List, Form and Relation behaviours, and describes its screens in config_list.yaml, config_form.yaml and the model's columns.yaml and fields.yaml. SummerCMS keeps the YAML, and drops the controller class: cabana compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas.

Declaring a controller

A controller is a small Go type that implements pact.AdminController: its ID, the model name that modelClass in the YAML must match, and the directory that holds its YAML. It usually also implements pact.AdminRecordSource, which returns the GORM model to query, and pact.AdminPermissioned, the permissions an administrator needs. The plugin returns its controllers from pact.HasAdminControllers and its embedded YAML tree from pact.AdminAssets:

package cabana_test

import (
	"io/fs"
	"os"
	"time"

	"git.golem15.com/golem15/summercms/modules/backpack"
	"git.golem15.com/golem15/summercms/modules/pact"
)

// Post is the model behind the acme.blog posts controller.
type Post struct {
	ID          uint       `gorm:"column:id;primaryKey"`
	Title       string     `gorm:"column:title"`
	Slug        string     `gorm:"column:slug"`
	Status      string     `gorm:"column:status"`
	Published   bool       `gorm:"column:published"`
	PublishedAt *time.Time `gorm:"column:published_at"`
	Content     string     `gorm:"column:content"`
}

func (Post) TableName() string { return "acme_blog_posts" }

// Fillable lists the columns the admin form may write.
func (Post) Fillable() []string {
	return []string{"title", "slug", "status", "published", "content"}
}

// PostsController is the admin controller for posts: the Go form of a
// WinterCMS controller with the List and Form behaviours.
type PostsController struct{}

var (
	_ pact.AdminController     = PostsController{}
	_ pact.AdminRecordSource   = PostsController{}
	_ pact.AdminPermissioned   = PostsController{}
	_ pact.HasAdminControllers = (*BlogPlugin)(nil)
)

// ID is the controller ID; its admin API path is /acme/blog/posts.
func (PostsController) ID() string { return "acme.blog.posts" }

// ModelName must equal modelClass in the YAML.
func (PostsController) ModelName() string { return "Post" }

// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
func (PostsController) ConfigDir() string { return "controllers/posts" }

// NewRecord returns the model the generic admin handlers query.
func (PostsController) NewRecord() any { return &Post{} }

// RequiredPermissions are checked before any schema or query.
func (PostsController) RequiredPermissions() []string {
	return []string{"acme.blog.access_posts"}
}

// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
	ID              uint `gorm:"column:id;primaryKey"`
	PostsPerPage    int  `gorm:"column:posts_per_page"`
	CommentsEnabled bool `gorm:"column:comments_enabled"`
}

func (BlogSettings) TableName() string { return "acme_blog_settings" }

func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }

// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
	return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}

// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
type BlogPlugin struct{}

var (
	_ pact.AdminAssets    = (*BlogPlugin)(nil)
	_ pact.HasPermissions = (*BlogPlugin)(nil)
	_ pact.HasNavigation  = (*BlogPlugin)(nil)
	_ pact.HasSettings    = (*BlogPlugin)(nil)
)

func (p *BlogPlugin) ID() string                       { return "acme.blog" }
func (p *BlogPlugin) Requires() []string               { return nil }
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
func (p *BlogPlugin) Boot(app *backpack.App) error     { return nil }

// AdminControllers registers the plugin's admin controllers.
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
	return []pact.AdminController{PostsController{}}
}

// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
// plugin returns an embed.FS; the example reads the same files from testdata.
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }

// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
	return []pact.Permission{
		{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
		{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
	}
}

// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
	return []pact.NavigationItem{{
		Code:        "blog",
		Label:       "acme.blog::lang.plugin.name",
		Icon:        "icon-pencil",
		Permissions: []string{"acme.blog.access_posts"},
		Controller:  "acme.blog.posts",
		SideMenu: []pact.NavigationItem{
			{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
		},
	}}
}

// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
	return []pact.SettingsItem{{
		Code:        "blog",
		Label:       "acme.blog::lang.settings.label",
		Description: "acme.blog::lang.settings.description",
		Category:    "acme.blog::lang.plugin.name",
		Icon:        "icon-pencil",
		Model:       "BlogSettings",
		Permissions: []string{"acme.blog.access_settings"},
		Form:        "models/settings/fields.yaml",
		NewModel:    func() any { return &BlogSettings{} },
	}}
}

summer make:admin-controller writes the controller type and its four YAML files:

summer make:admin-controller acme.blog Posts

The generated controller requires the permission acme.blog.access_posts, so declare it in the plugin's pact.HasPermissions or the admin API refuses to start with an unknown-permission error. Its NewRecord returns nil until you return the model, and the list and every write answer 500 until then. The controller ID maps to the admin API path: acme.blog.posts is served under <prefix>/api/v1/acme/blog/posts. The admin prefix is backend.uri, /backend by default. A model's Fillable method decides which form fields the API may write; see Forms. The admin finds a model's columns through its struct fields, including those of an embedded struct such as gorm.Model. A field with a gorm:"column:..." tag is known by that column alone; an untagged field is known by GORM's default column name.

Compilation at boot

At start-up cabana.Activate compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a modelClass that does not match pact.AdminController.ModelName, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above:

cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
	fmt.Println(err)
	return
}
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
_ = cfg.Set("backend.uri", "/admin")

routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(routes.Prefix)

editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
// Output:
// /admin
// true
// false

admin.jwt.secret is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed.

The admin API

Every controller gets the same routes, relative to <prefix>/api/v1/{vendor}/{plugin}/{controller}:

Method and path Does
GET /schema/list, GET /schema/form The list and form schemas, translated into the request locale.
GET / Lists records with search, sort, filters and pagination, allowed only on columns the schema declares.
POST / Creates a record.
GET /{id}, PUT /{id}, DELETE /{id} Shows, updates and deletes a record.
POST /bulk-delete Deletes a set of records in one transaction.

The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (cabana.Envelope), and a validation failure is a 422 validation_failed error with messages per field.

Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through lagoon.Fill and validated through lagoon.Validate in a transaction.

Hooks

Behaviour overrides such as formBeforeCreate or listExtendQuery become optional interfaces on the controller. cabana checks for each one and calls it at the matching point:

Interface Runs
pact.ListExtendQuery Scopes every list query, for example to the administrator's own records.
pact.FormExtendQuery Scopes every show, update and delete lookup, so a record outside the scope is a 404.
pact.FormBeforeCreate, pact.FormAfterCreate Around a create, inside its transaction.
pact.FormBeforeUpdate, pact.FormAfterUpdate Around an update, inside its transaction.
pact.FormBeforeDelete, pact.FormAfterDelete Around a delete, inside its transaction.
pact.DropdownOptionsProvider Supplies the options of a dropdown field that names a method.

A hook or scope that has to read the database during a write should use the transaction the write runs in, cabana.TxFromContext(ctx), rather than the application pool: it then sees the write's own snapshot and takes no second connection while the transaction holds row locks. The handle is valid only until the hook returns, and the list route, which runs no transaction, reports none.

Scope reads and writes with pact.ListExtendQuery and pact.FormExtendQuery rather than checking in a hook: the scope then applies to every route, including relation and action routes.

Form-only values and rules

A form may collect values that are not columns of the record (see Form-only fields). The Form hooks read what the administrator submitted with cabana.VirtualFieldsFromContext(ctx). The map holds only the fields that were sent and that the field's context allows for this operation, so a missing key means "not submitted". Values are scalars as decoded from the request: a string, a bool, a json.Number or nil. The map is a copy, and the second result is false outside a create or update.

// FormBeforeCreate reads the submitted virtual values, which have passed the
// rules by now, and stores what the model needs.
func (MembersController) FormBeforeCreate(ctx context.Context, model any) error {
	values, _ := cabana.VirtualFieldsFromContext(ctx)
	member := model.(*Member)
	if plain, ok := values["password"].(string); ok && plain != "" {
		member.Password = hashPassword(plain)
	}
	if notify, _ := values["notify"].(bool); notify {
		// Queue the welcome message here.
	}
	return nil
}

A save validates the record against the model's Rules(). Those are often the rules of a public sign-up, which an admin form cannot meet: an update that changes only a name would have to repeat the password. A controller implementing pact.FormRules returns the rules of an admin save for create or update, and that set replaces the model's:

// FormRules replaces the model's rules for admin saves: a create needs a
// password, an update takes one only when the administrator types it.
func (MembersController) FormRules(_ context.Context, op string) map[string]string {
	rules := map[string]string{"name": "required"}
	if op == "create" {
		rules["password"] = "required|between:8,255|confirmed"
	} else {
		rules["password"] = "nullable|between:8,255|confirmed"
	}
	return rules
}
  • The form's required flags are still merged in, also for form-only fields.
  • A rule on a form-only field is checked against the submitted value, or against nothing when the field was not sent; the model column of the same name is never read. confirmed compares with the submitted <field>_confirmation.
  • Rule strings use the tokens lagoon.Validate supports. An unknown token is the opaque 500 on every save, so checks outside that set belong in a hook that returns a cabana.ValidationError.
  • The model must still have a Rules method; relation forms and settings forms keep using the model's rules.

Refusing a write

A hook or an action stops a write by returning an error. Which error decides what the administrator sees:

Error Answer
cabana.ValidationError 422 validation_failed, with its Details as messages per field.
cabana.ForbiddenError 403 forbidden, with its Message and its Details.
any other error The opaque 500. The error is logged on the server and its text never reaches the client.

Return a cabana.ForbiddenError when the signed-in administrator may open the screen but may not make this particular change, for example editing a record that needs a higher permission. Message is a translation key or text; cabana translates it, and every Details message, in the request locale. Details maps a field name to a list of messages, as a cabana.ValidationError does, and may be left out. With an empty Message the admin shows its own text.

Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. A cabana.ForbiddenError works the same from the form hooks, the relation hooks, and bulk, record, toolbar and widget actions.

Toolbar actions

toolbar.buttons in config_list.yaml lists the built-in create and delete and any action the controller registers through pact.HasAdminActions. The declarations are enforced by the server, not only shown by the SPA. POST /{controller} needs a config_form.yaml and create in toolbar.buttons; PUT and DELETE /{controller}/{id} need a form (the form screen carries the delete button, as in WinterCMS); POST /{controller}/bulk-delete needs delete in toolbar.buttons, which in turn needs showCheckboxes: true. A write the controller does not declare answers 403 forbidden. See Partials and widgets for actions and the rest of the extension points.

Bulk actions

A bulk action runs on the rows an administrator selected in the list, as the onBulkAction handlers of a WinterCMS list toolbar do. The controller registers its bulk actions through pact.HasAdminBulkActions, and bulkActions in config_list.yaml lists the ones the list offers, in menu order:

package cabana_test

import (
	"context"
	"errors"

	"git.golem15.com/golem15/summercms/modules/cabana"
	"git.golem15.com/golem15/summercms/modules/pact"
)

// Person is the model behind the acme.roster people controller.
type Person struct {
	ID     uint   `gorm:"column:id;primaryKey"`
	Name   string `gorm:"column:name"`
	Email  string `gorm:"column:email"`
	Active bool   `gorm:"column:active"`
	Banned bool   `gorm:"column:banned"`
}

func (Person) TableName() string { return "acme_roster_people" }

// Fillable lists the columns the admin form may write.
func (Person) Fillable() []string { return []string{"name", "email"} }

// PeopleController is an admin controller with bulk and record actions.
type PeopleController struct{}

var (
	_ pact.AdminController       = PeopleController{}
	_ pact.AdminRecordSource     = PeopleController{}
	_ pact.HasAdminBulkActions   = PeopleController{}
	_ pact.HasAdminRecordActions = PeopleController{}
)

func (PeopleController) ID() string        { return "acme.roster.people" }
func (PeopleController) ModelName() string { return "Person" }
func (PeopleController) ConfigDir() string { return "controllers/people" }
func (PeopleController) NewRecord() any    { return &Person{} }

func (PeopleController) RequiredPermissions() []string {
	return []string{"acme.roster.access"}
}

// AdminBulkActions registers the actions config_list.yaml offers under
// bulkActions. Run receives the selected records, already loaded and locked
// through the list scope, and writes through the request's transaction.
func (PeopleController) AdminBulkActions() []pact.AdminBulkAction {
	return []pact.AdminBulkAction{{
		Name:        "activate",
		Label:       "acme.roster::lang.people.activate",
		Confirm:     "acme.roster::lang.people.activate_confirm",
		Permissions: []string{"acme.roster.manage"},
		Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
			tx, ok := cabana.TxFromContext(ctx)
			if !ok {
				return pact.AdminBulkActionResult{}, errors.New("no transaction")
			}
			changed := 0
			for _, record := range in.Records {
				person := record.(*Person)
				if person.Active {
					continue
				}
				if err := tx.Model(person).Update("active", true).Error; err != nil {
					return pact.AdminBulkActionResult{}, err
				}
				changed++
			}
			return pact.AdminBulkActionResult{Affected: changed}, nil
		},
	}, {
		Name:  "archive",
		Label: "acme.roster::lang.people.archive",
		Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
			tx, ok := cabana.TxFromContext(ctx)
			if !ok {
				return pact.AdminBulkActionResult{}, errors.New("no transaction")
			}
			for _, record := range in.Records {
				if err := tx.Delete(record).Error; err != nil {
					return pact.AdminBulkActionResult{}, err
				}
			}
			return pact.AdminBulkActionResult{
				Message:  "acme.roster::lang.people.archived",
				Affected: len(in.Records),
			}, nil
		},
	}}
}

// AdminRecordActions registers the actions config_form.yaml offers under
// recordActions. Applies decides whether an action fits the record's current
// state; Run receives the record loaded and locked through the form scope.
func (PeopleController) AdminRecordActions() []pact.AdminRecordAction {
	return []pact.AdminRecordAction{{
		Name:        "activate",
		Label:       "acme.roster::lang.people.activate",
		Permissions: []string{"acme.roster.manage"},
		Applies: func(_ context.Context, record any) (bool, error) {
			return !record.(*Person).Active, nil
		},
		Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
			tx, ok := cabana.TxFromContext(ctx)
			if !ok {
				return pact.AdminRecordActionResult{}, errors.New("no transaction")
			}
			if err := tx.Model(in.Record).Update("active", true).Error; err != nil {
				return pact.AdminRecordActionResult{}, err
			}
			return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil
		},
	}, {
		Name:    "reinstate",
		Label:   "acme.roster::lang.people.reinstate",
		Confirm: "acme.roster::lang.people.reinstate_confirm",
		Applies: func(_ context.Context, record any) (bool, error) {
			return record.(*Person).Banned, nil
		},
		Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
			tx, ok := cabana.TxFromContext(ctx)
			if !ok {
				return pact.AdminRecordActionResult{}, errors.New("no transaction")
			}
			if err := tx.Model(in.Record).Update("banned", false).Error; err != nil {
				return pact.AdminRecordActionResult{}, err
			}
			return pact.AdminRecordActionResult{}, nil
		},
	}}
}
list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/preview/:id
recordsPerPage: 20
showCheckboxes: true
toolbar:
    buttons: [create, delete]
    search:
        prompt: backend::lang.list.search_prompt
bulkActions: [activate, archive]
messages:
    create: acme.roster::lang.people.create
    rowStateDisabled: acme.roster::lang.people.state_inactive

The admin SPA shows the declared actions in a "Bulk actions" menu next to the selection, asks for confirmation (the action's Confirm text, or a default one), and posts the selected ids to POST /{controller}/bulk/{action}. cabana owns that route:

  • The ids are resolved and row-locked through the controller's pact.ListExtendQuery scope inside one transaction. Run receives the loaded records in pact.AdminBulkActionInput, never the ids, so an id outside the scope cannot reach plugin code.
  • A selection that matches no row in the scope answers affected: 0 without calling Run. A selection of which only a part is in the scope answers 409 conflict and changes nothing.
  • Run writes through cabana.TxFromContext(ctx). Any error rolls every row back; a cabana.ValidationError is a 422 and any other error the opaque 500.
  • The action's Permissions are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
  • pact.AdminBulkActionResult carries an optional Message (a phrase key or text) and Affected, the number of records the action changed. The answer is a cabana.BulkActionResult.

Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and create and delete stay reserved. bulkActions needs showCheckboxes: true. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button.

Record actions

A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through pact.HasAdminRecordActions (PeopleController above registers two), and recordActions in config_form.yaml lists the ones the form offers, in display order:

name: acme.roster::lang.people.form
form: ~/plugins/acme/roster/models/person/fields.yaml
modelClass: Person
defaultRedirect: acme/roster/people
create:
    redirect: acme/roster/people/preview/:id
    redirectClose: acme/roster/people
update:
    redirect: acme/roster/people
    redirectClose: acme/roster/people/preview/:id
preview:
    headerPartial: status
recordActions: [activate, reinstate]
messages:
    preview: acme.roster::lang.people.preview
    edit: acme.roster::lang.people.edit

Each pact.AdminRecordAction has a Name, a Label, an optional Confirm text, its own Permissions, an optional Applies function and Run:

  • Applies reports whether the action fits the record's current state; without one the action always applies. It must only read, because it runs in two places: when a record is shown, to decide which actions to offer, and again inside the action's transaction, right before Run.
  • The show response (GET /{controller}/{id}) lists the offered actions in meta.actions as cabana.RecordAction entries: only those the administrator may run and that apply to the record. The key is absent when none is offered, and create and update responses never carry it.
  • POST /{controller}/{id}/actions/{action} takes an empty {} body. cabana loads the record through pact.FormExtendQuery with a row lock in one transaction and hands it to Run in pact.AdminRecordActionInput. A missing record and a record outside the scope are the same 404; an action whose Applies reports false answers 409 conflict; an administrator without the action's permissions gets 403.
  • Run writes through cabana.TxFromContext(ctx) and returns a pact.AdminRecordActionResult with an optional Message. Any error rolls the transaction back.

Record actions have their own namespace: a record action and a bulk action may share a name, such as activate here. create and delete are reserved. A name in recordActions that the controller does not register, a duplicate, or an action without a label stops the start-up.