feat(18-04): settings forms take method-backed dropdown options from the settings model (task 1)

- the settings schema passes the settings model as the DropdownOptionsProvider
- boot refuses a dropdown method on a settings model without DropdownOptions
- cabana README and docs/backend/settings.md describe it
This commit is contained in:
2026-10-13 14:08:14 +02:00
parent dff931c865
commit 21c5618dd1
5 changed files with 83 additions and 2 deletions

View File

@@ -62,6 +62,8 @@ fields:
span: right
```
A dropdown whose `options` names a method (`options: getCurrencyOptions`) takes its choices from the settings model, as a WinterCMS settings model's `get*Options` method did. The model implements `pact.DropdownOptionsProvider`; `DropdownOptions` receives the field name and returns `pact.Option` values whose labels may be phrase keys. A settings model without it stops the start-up naming the field.
Create the table in a migration like any other; see [Migrations](../database/migrations.md).
## Linking a settings entry to an admin controller

View File

@@ -50,7 +50,7 @@ Use cases:
- 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 or a bulk, record, toolbar or widget action 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. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- 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 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.
- 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 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.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
- Personal API tokens: administrators mint scoped tokens stored in `backend_api_tokens` (`cabana.BackendAPIToken`); only the sha256 hash and a four-character hint are kept. Framework scopes are `*` (full access as the owner on every method) and `admin:read` (as the owner on GET and HEAD); plugins add scopes through `pact.HasTokenScopes`. Interactive-only routes (account profile, avatar, token management, refresh and logout) refuse every personal token with `details.reason` `interactive_only`. Routes outside `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) are gated so only a full scope, or `admin:read` on GET, passes. Plugin routes resolve a backend principal with `cabana.ResolveBackend` (Bearer only; never the admin cookie).

View File

@@ -512,7 +512,7 @@ func (s *service) settingsList(w http.ResponseWriter, r *http.Request) {
func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) {
view, err := setting.Form.Localize(r.Context(), s.translator(), nil)
view, err := setting.Form.Localize(r.Context(), s.translator(), settingDropdownProvider(setting))
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return

View File

@@ -108,6 +108,11 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
if err := settingsFormOnly(field); err != nil {
return nil, fmt.Errorf("cabana: setting %s field %s: %w", item.Code, field.Name, err)
}
// A method-backed dropdown takes its options from the settings model,
// as a relation form takes them from its related model.
if field.optionsMethod != "" && modelDropdownProvider(model) == nil {
return nil, fmt.Errorf("cabana: setting %s field %s: dropdown method %s requires DropdownOptions on the settings model", item.Code, field.Name, field.optionsMethod)
}
}
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
columns := modelColumns(model)
@@ -151,6 +156,16 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable, Secrets: secrets}, nil
}
// settingDropdownProvider is the settings model of setting as the provider
// of its method-backed dropdown options, or nil.
func settingDropdownProvider(setting *CompiledSetting) pact.DropdownOptionsProvider {
model, err := newSettingModel(setting)
if err != nil {
return nil
}
return modelDropdownProvider(model)
}
// Get returns default-valued data without creating a missing singleton.
func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (SettingsResult, error) {
if s.DB == nil {

View File

@@ -0,0 +1,64 @@
package cabana
import (
"context"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/pact"
)
// optionSettings is a settings model whose dropdown options come from a
// model method, like a WinterCMS settings model's get*Options.
type optionSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Currency string `gorm:"column:currency"`
}
func (optionSettings) TableName() string { return "cabana_option_settings" }
func (optionSettings) Fillable() []string { return []string{"currency"} }
func (optionSettings) Rules() map[string]string { return map[string]string{} }
func (optionSettings) DropdownOptions(field string) []pact.Option {
if field != "currency" {
return nil
}
return []pact.Option{{Value: "EUR", Label: "EUR — Euro"}, {Value: "PLN", Label: "PLN — Polish zloty"}}
}
// plainSettings has the same column and no DropdownOptions.
type plainSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Currency string `gorm:"column:currency"`
}
func (plainSettings) TableName() string { return "cabana_plain_settings" }
func (plainSettings) Fillable() []string { return []string{"currency"} }
func (plainSettings) Rules() map[string]string { return map[string]string{} }
// TestSettingsDropdownMethod checks that a settings form takes a
// method-backed dropdown's options from its settings model, and that boot
// refuses the method when the model has no DropdownOptions.
func TestSettingsDropdownMethod(t *testing.T) {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n currency:\n label: Currency\n type: dropdown\n options: getCurrencyOptions\n")}}
item := pact.SettingsItem{Code: "options", Label: "Options", Form: "models/settings/fields.yaml", NewModel: func() any { return &optionSettings{} }}
setting, err := compileSetting("acme.unit", item, fsys)
if err != nil {
t.Fatal(err)
}
view, err := setting.Form.Localize(context.Background(), nil, settingDropdownProvider(setting))
if err != nil {
t.Fatal(err)
}
opts := view.Fields[0].Options
if len(opts) != 2 || opts[1].Label != "PLN — Polish zloty" {
t.Fatalf("options = %+v", opts)
}
item.NewModel = func() any { return &plainSettings{} }
_, err = compileSetting("acme.unit", item, fsys)
if err == nil || !strings.Contains(err.Error(), "dropdown method getCurrencyOptions requires DropdownOptions on the settings model") {
t.Fatalf("err = %v", err)
}
}