From 4a9b0896b1dfc0eff33a20aac0331e63455f109d Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 7 Oct 2026 18:22:01 +0200 Subject: [PATCH] feat(cabana): settings entries that link to an admin controller pact.SettingsItem gains an additive Controller field, the equivalent of a WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry declares no Model, Form or NewModel and needs no AdminFS; start-up fails when it combines Controller with a singleton form or a model, or names an unregistered controller. Settings codes stay one namespace. GET /settings lists a link entry with its controller only when the principal passes the item's permissions and may open the controller. Registry.Setting never returns a link entry, so the singleton settings endpoints answer 404 for its code. SettingsEntry carries controller, empty for singletons; the OpenAPI document, generated SPA types and settings fixture follow, and the pact and cabana READMEs and the settings docs describe the link. --- admin/openapi/admin.json | 5 + admin/src/api/schema.d.ts | 5 + admin/tests/fixtures/settings.json | 3 + docs/backend/settings.md | 23 ++- docs/setup/coming-from-wintercms.md | 2 +- modules/cabana/README.md | 6 +- modules/cabana/contracts.go | 9 +- modules/cabana/example_settings_test.go | 24 +++ modules/cabana/example_test.go | 17 ++ modules/cabana/navigation.go | 8 +- modules/cabana/registry.go | 15 ++ modules/cabana/settings.go | 16 +- modules/cabana/settings_link_test.go | 202 ++++++++++++++++++++++++ modules/pact/README.md | 4 +- modules/pact/capabilities.go | 13 +- 15 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 modules/cabana/example_settings_test.go create mode 100644 modules/cabana/settings_link_test.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 7a9d4a6..dc747df 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1875,6 +1875,10 @@ "code": { "type": "string" }, + "controller": { + "description": "Controller is the admin controller a link entry opens; empty for a\nsingleton settings form.", + "type": "string" + }, "description": { "type": "string" }, @@ -1900,6 +1904,7 @@ "required": [ "category", "code", + "controller", "description", "icon", "keywords", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 670708f..ded7677 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -4886,6 +4886,11 @@ export interface components { "cabana.SettingsEntry": { category: string; code: string; + /** + * @description Controller is the admin controller a link entry opens; empty for a + * singleton settings form. + */ + controller: string; description: string; icon: string; keywords: string[]; diff --git a/admin/tests/fixtures/settings.json b/admin/tests/fixtures/settings.json index fd05a03..2f3cc9d 100644 --- a/admin/tests/fixtures/settings.json +++ b/admin/tests/fixtures/settings.json @@ -9,6 +9,7 @@ "category": "System", "keywords": [], "model": "Acme\\Demo\\Models\\Settings", + "controller": "", "order": 20 }, { @@ -19,6 +20,7 @@ "category": "System", "keywords": [], "model": "Acme\\Demo\\Models\\MailSettings", + "controller": "", "order": 10 }, { @@ -29,6 +31,7 @@ "category": "Appearance", "keywords": [], "model": "Acme\\Demo\\Models\\Branding", + "controller": "", "order": 5 } ], diff --git a/docs/backend/settings.md b/docs/backend/settings.md index 21a41ad..96aca67 100644 --- a/docs/backend/settings.md +++ b/docs/backend/settings.md @@ -1,6 +1,6 @@ --- title: Settings -description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code. +description: Declare settings pages with pact.SettingsItem, as singleton forms backed by a model or as links to admin controllers, and read settings from plugin code. section: backend order: 60 --- @@ -64,6 +64,27 @@ fields: Create the table in a migration like any other; see [Migrations](../database/migrations.md). +## Linking a settings entry to an admin controller + +A WinterCMS `registerSettings` entry can point at a backend controller instead of a settings model, with `'url' => Backend::url('acme/blog/posts')`. The entry appears on the Settings page and opens that controller's list. In SummerCMS the `Controller` field of `pact.SettingsItem` does the same: set it to the admin controller ID (`vendor.plugin.controller`) and leave `Model`, `Form` and `NewModel` empty. The plugin returns this entry from `Settings` next to, or instead of, its singleton pages: + +```go src=modules/cabana/example_settings_test.go#settings-link +link := pact.SettingsItem{ + Code: "posts", + Label: "acme.blog::lang.posts.title", + Description: "acme.blog::lang.posts.description", + Category: "acme.blog::lang.plugin.name", + Icon: "icon-pencil", + Order: 500, + Permissions: []string{"acme.blog.access_settings"}, + Controller: "acme.blog.posts", +} +``` + +A link entry is not a singleton and needs no embedded admin tree. [cabana](../../modules/cabana/README.md) stops start-up when a link entry also declares `Model`, `Form` or `NewModel`, when it names a controller no plugin registered, or when its code repeats another settings code; singleton and link entries share one code namespace. + +`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code. + ## How values are stored The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does. diff --git a/docs/setup/coming-from-wintercms.md b/docs/setup/coming-from-wintercms.md index 9891784..243f04e 100644 --- a/docs/setup/coming-from-wintercms.md +++ b/docs/setup/coming-from-wintercms.md @@ -35,7 +35,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it, | Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) | | `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) | | Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) | -| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) | +| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries; an entry with `'url' => Backend::url(...)` sets the `Controller` field to the admin controller ID | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) | | Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) | | Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) | | The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) | diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 15dc377..9cc481b 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -30,7 +30,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - 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. -- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. +- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. 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: 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). - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. - A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body. @@ -46,7 +46,7 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. | | GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. | | POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. | -| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. | +| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. | | GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. | | POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. | | GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. | @@ -191,7 +191,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. | | `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. | | `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. | -| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. | +| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. | | `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. | | `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. | | `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. | diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index ef5015f..dfdcc8d 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -131,13 +131,18 @@ func (r *Registry) Get(id string) (*CompiledController, bool) { return cc, ok && cc != nil } -// Setting returns one compiled singleton setting by stable code. +// Setting returns one compiled singleton setting by stable code. Link entries +// (pact.SettingsItem with Controller set) are not singletons and are not +// returned, so the singleton settings endpoints answer 404 for their codes. func (r *Registry) Setting(code string) (*CompiledSetting, bool) { if r == nil { return nil, false } setting, ok := r.settings[code] - return setting, ok && setting != nil + if !ok || setting == nil || setting.Item.Controller != "" { + return nil, false + } + return setting, true } // Allows reports whether principal satisfies any of the required permission diff --git a/modules/cabana/example_settings_test.go b/modules/cabana/example_settings_test.go new file mode 100644 index 0000000..0c3f6c1 --- /dev/null +++ b/modules/cabana/example_settings_test.go @@ -0,0 +1,24 @@ +package cabana_test + +import "git.golem15.com/golem15/summercms/modules/pact" + +// LinkedBlogPlugin adds a settings entry that opens the posts controller from +// the Settings page. +type LinkedBlogPlugin struct{ BlogPlugin } + +// Settings keeps the singleton page and adds a controller link. +func (p *LinkedBlogPlugin) Settings() []pact.SettingsItem { + // docs:start settings-link + link := pact.SettingsItem{ + Code: "posts", + Label: "acme.blog::lang.posts.title", + Description: "acme.blog::lang.posts.description", + Category: "acme.blog::lang.plugin.name", + Icon: "icon-pencil", + Order: 500, + Permissions: []string{"acme.blog.access_settings"}, + Controller: "acme.blog.posts", + } + // docs:end settings-link + return append(p.BlogPlugin.Settings(), link) +} diff --git a/modules/cabana/example_test.go b/modules/cabana/example_test.go index 8b712bb..aaa96ad 100644 --- a/modules/cabana/example_test.go +++ b/modules/cabana/example_test.go @@ -101,3 +101,20 @@ func TestDocsDeclarations(t *testing.T) { t.Fatal("BlogSettings has no posts_per_page rule") } } + +// TestDocsSettingsLink activates the plugin with a settings entry that links +// to its posts controller, as the Settings page shows. +func TestDocsSettingsLink(t *testing.T) { + cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) + if err != nil { + t.Fatal(err) + } + _ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes") + if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&LinkedBlogPlugin{}}); err != nil { + t.Fatal(err) + } + settings := (&LinkedBlogPlugin{}).Settings() + if len(settings) != 2 || settings[1].Controller != "acme.blog.posts" || settings[1].Model != "" { + t.Fatalf("Settings() = %+v", settings) + } +} diff --git a/modules/cabana/navigation.go b/modules/cabana/navigation.go index 85f817e..4b10d90 100644 --- a/modules/cabana/navigation.go +++ b/modules/cabana/navigation.go @@ -29,6 +29,9 @@ type SettingsEntry struct { Order int `json:"order"` Keywords []string `json:"keywords"` Model string `json:"model"` + // Controller is the admin controller a link entry opens; empty for a + // singleton settings form. + Controller string `json:"controller"` } // Metadata returns only entries the backend principal may open. Denied @@ -65,7 +68,9 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t }) for _, compiled := range r.settings { item := compiled.Item - if !Allows(principal, item.Permissions) { + // A link entry is listed only when the principal can also open its + // controller, so a denied controller ID never leaks. + if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) { continue } keywords := append([]string(nil), item.Keywords...) @@ -77,6 +82,7 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t Description: translateKey(ctx, tr, item.Description), Category: translateKey(ctx, tr, item.Category), Icon: item.Icon, Order: item.Order, Keywords: keywords, Model: item.Model, + Controller: item.Controller, }) } sort.SliceStable(settings, func(i, j int) bool { diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index 37b5e19..0246775 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -196,6 +196,16 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error { if _, exists := reg.settings[item.Code]; exists { return fmt.Errorf("cabana: duplicate setting %s", item.Code) } + if item.Controller != "" { + // A link entry opens an admin controller and needs no + // singleton form, so no AdminFS either. + compiled, err := compileSettingLink(plugin.ID(), item) + if err != nil { + return err + } + reg.settings[item.Code] = compiled + continue + } if !hasAssets || assets == nil || assets.AdminFS() == nil { return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID()) } @@ -250,6 +260,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error { if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil { return err } + if controller := setting.Item.Controller; controller != "" { + if _, ok := reg.byID[controller]; !ok { + return fmt.Errorf("cabana: setting %s references unknown controller %s", code, controller) + } + } } return nil } diff --git a/modules/cabana/settings.go b/modules/cabana/settings.go index f1230e7..72fccbf 100644 --- a/modules/cabana/settings.go +++ b/modules/cabana/settings.go @@ -17,7 +17,8 @@ import ( ) // CompiledSetting is a singleton settings registration after strict schema -// compilation and writable-field binding. +// compilation and writable-field binding. For a link entry (Item.Controller +// set) Form and Writable are nil. type CompiledSetting struct { PluginID string Item pact.SettingsItem @@ -34,6 +35,19 @@ type SettingsResult struct { // SettingsService reads and transactionally updates compiled singleton rows. type SettingsService struct{ DB *gorm.DB } +// compileSettingLink registers a settings entry that opens an admin +// controller. It declares no singleton form and no model; the controller ID +// itself is checked against the registry once every plugin is collected. +func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error) { + if item.Form != "" || item.NewModel != nil { + return nil, fmt.Errorf("cabana: setting %s links controller %s and declares a singleton form; declare one", item.Code, item.Controller) + } + if item.Model != "" { + return nil, fmt.Errorf("cabana: setting %s links controller %s and names a model", item.Code, item.Controller) + } + return &CompiledSetting{PluginID: pluginID, Item: item}, nil +} + func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) { if item.NewModel == nil { return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code) diff --git a/modules/cabana/settings_link_test.go b/modules/cabana/settings_link_test.go new file mode 100644 index 0000000..5236cd4 --- /dev/null +++ b/modules/cabana/settings_link_test.go @@ -0,0 +1,202 @@ +package cabana + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// settingsLinkItem is a settings entry that opens an admin controller instead +// of a singleton form. +func settingsLinkItem() pact.SettingsItem { + return pact.SettingsItem{ + Code: "locales", Label: "Locales", Description: "Manage locales", Category: "Demo", + Icon: "languages", Order: 30, Permissions: []string{"acme.demo.manage_settings"}, + Controller: "acme.demo.widgets", + } +} + +// settingsLinkPlugin declares only a link entry and ships no admin assets. +func settingsLinkPlugin(items ...pact.SettingsItem) contributionPlugin { + return contributionPlugin{ + id: "acme.links", + permissions: []pact.Permission{{Code: "acme.links.unused", Roles: []string{"developer"}}}, + settings: items, + } +} + +// settingsLinkRegistry compiles the metadata plugin (one singleton setting) +// together with a plugin holding one controller link. +func settingsLinkRegistry(t *testing.T) *Registry { + t.Helper() + reg := metadataRegistry() + if err := compileContributions(reg, []party.Plugin{metadataPlugin(), settingsLinkPlugin(settingsLinkItem())}); err != nil { + t.Fatal(err) + } + return reg +} + +func TestSettingsLinkCompilesWithoutAdminFS(t *testing.T) { + reg := settingsLinkRegistry(t) + stored, ok := reg.settings["locales"] + if !ok || stored == nil { + t.Fatal("link entry was not registered") + } + if stored.Form != nil || stored.Writable != nil || stored.Item.Controller != "acme.demo.widgets" || stored.PluginID != "acme.links" { + t.Fatalf("link entry = %#v", stored) + } + if _, ok := reg.Setting("locales"); ok { + t.Fatal("Registry.Setting returned a link entry") + } + if setting, ok := reg.Setting("demo"); !ok || setting.Form == nil { + t.Fatalf("singleton lookup = %#v %v", setting, ok) + } +} + +func TestSettingsLinkCompileValidation(t *testing.T) { + withForm := settingsLinkItem() + withForm.Form = "models/settings/fields.yaml" + withFactory := settingsLinkItem() + withFactory.NewModel = func() any { return &singletonSetting{} } + withModel := settingsLinkItem() + withModel.Model = "Settings" + missing := settingsLinkItem() + missing.Controller = "acme.demo.missing" + duplicate := settingsLinkItem() + duplicate.Code = "demo" + unknownPermission := settingsLinkItem() + unknownPermission.Permissions = []string{"acme.demo.nope"} + + cases := []struct { + name string + item pact.SettingsItem + want string + }{ + {"form", withForm, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"}, + {"model factory", withFactory, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"}, + {"model", withModel, "cabana: setting locales links controller acme.demo.widgets and names a model"}, + {"unknown controller", missing, "cabana: setting locales references unknown controller acme.demo.missing"}, + {"duplicate code", duplicate, "duplicate setting demo"}, + {"unknown permission", unknownPermission, "unknown permission"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := compileContributions(metadataRegistry(), []party.Plugin{metadataPlugin(), settingsLinkPlugin(tc.item)}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestSettingsLinkMetadata(t *testing.T) { + reg := settingsLinkRegistry(t) + developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")} + _, settings := reg.Metadata(context.Background(), developer, nil) + if len(settings) != 2 { + t.Fatalf("developer settings = %#v", settings) + } + byCode := map[string]SettingsEntry{} + for _, entry := range settings { + byCode[entry.Code] = entry + } + link, single := byCode["locales"], byCode["demo"] + if link.Controller != "acme.demo.widgets" || link.Model != "" || link.Label != "Locales" || link.Order != 30 { + t.Fatalf("link entry = %#v", link) + } + if link.Keywords == nil { + t.Fatal("link entry keywords are nil") + } + if single.Controller != "" || single.Model != "Settings" { + t.Fatalf("singleton entry = %#v", single) + } + + // The principal passes the item's permission but cannot open the + // controller (it requires acme.demo.access): the link is hidden, the + // singleton stays. + restricted := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.demo.manage_settings": true}} + _, settings = reg.Metadata(context.Background(), restricted, nil) + if len(settings) != 1 || settings[0].Code != "demo" { + t.Fatalf("restricted settings = %#v", settings) + } + + // Controller access alone does not pass the item's own permission. + controllerOnly := &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}} + _, settings = reg.Metadata(context.Background(), controllerOnly, nil) + if len(settings) != 0 { + t.Fatalf("controller-only settings = %#v", settings) + } +} + +func TestSettingsLinkEndpoints(t *testing.T) { + reg := settingsLinkRegistry(t) + svc := &service{reg: reg} + developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")} + request := func(method, rel, code string) *http.Request { + var body *strings.Reader + if method == http.MethodPut { + body = strings.NewReader(`{"enabled":true}`) + } else { + body = strings.NewReader("") + } + req := httptest.NewRequest(method, adminAPI(rel), body) + if code != "" { + req.SetPathValue("code", code) + } + return req.WithContext(bouncer.WithUser(req.Context(), developer)) + } + + for _, tc := range []struct { + name string + method string + rel string + handler func(*service, http.ResponseWriter, *http.Request) + }{ + {"schema", http.MethodGet, "/settings/locales/schema", (*service).settingsSchema}, + {"get", http.MethodGet, "/settings/locales", (*service).settingsGet}, + {"put", http.MethodPut, "/settings/locales", (*service).settingsPut}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + tc.handler(svc, rec, request(tc.method, tc.rel, "locales")) + if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `"not_found"`) { + t.Fatalf("%s %s = %d %s", tc.method, tc.rel, rec.Code, rec.Body.String()) + } + }) + } + + rec := httptest.NewRecorder() + svc.settingsList(rec, request(http.MethodGet, "/settings", "")) + if rec.Code != http.StatusOK { + t.Fatalf("GET /settings = %d %s", rec.Code, rec.Body.String()) + } + var envelope struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Data) != 2 { + t.Fatalf("GET /settings data = %#v", envelope.Data) + } + for _, entry := range envelope.Data { + controller, ok := entry["controller"] + if !ok { + t.Fatalf("entry without controller key: %#v", entry) + } + want := "" + if entry["code"] == "locales" { + want = "acme.demo.widgets" + } + if controller != want { + t.Fatalf("entry %v controller = %v, want %q", entry["code"], controller, want) + } + } +} diff --git a/modules/pact/README.md b/modules/pact/README.md index 7e95781..84afcdb 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -12,7 +12,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf - Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`. - HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`. -- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. +- 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. - 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), `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). - 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`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), 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`). @@ -100,7 +100,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.HasMailTemplates` | Ships mail templates and layout aliases. | | `pact.Permission` | One backend permission entry (code, tab, label, roles). | | `pact.NavigationItem` | One backend navigation entry, with an optional side menu. | -| `pact.SettingsItem` | One settings screen entry. | +| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. | | `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. | | `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. | | `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index efd57b0..ab81562 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -398,7 +398,9 @@ type HasNavigation interface { Navigation() []NavigationItem } -// SettingsItem is one registerSettings() entry. +// SettingsItem is one registerSettings() entry. An item is either a singleton +// settings form (Model, Form and NewModel) or a link to an admin controller +// (Controller), never both. type SettingsItem struct { Code string Label string @@ -409,8 +411,13 @@ type SettingsItem struct { Order int Keywords []string Permissions []string - Form string `json:"-"` - NewModel func() any `json:"-"` + // Controller, when set, makes the entry a link to that admin controller + // ID (vendor.plugin.controller): the equivalent of a WinterCMS + // registerSettings 'url' => Backend::url(...) entry. A link entry + // declares no Model, Form or NewModel and is not a singleton. + Controller string + Form string `json:"-"` + NewModel func() any `json:"-"` } // HasSettings is implemented by plugins that declare settings screens.