From f9281949a6da74e70d5d5ba6be6538c61b35cbc5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 28 Sep 2026 23:35:00 +0200 Subject: [PATCH] feat(10.1-01): run registered widget actions through a cabana-owned route - pact: AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult, HasAdminActions and AdminPartialData contracts - fields.yaml type: widget with widget, action and fill keys; boot checks the plugin tag prefix, the registered action and writable scalar fill fields - POST .../widgets/{field} behind requireAjax, controller and action permissions, scoped non-locking record read and a server-side fill filter - typed OpenAPI operation, inventories and an acme conformance case --- admin/openapi/admin.json | 177 ++++++++++++++++++ admin/src/api/schema.d.ts | 113 ++++++++++++ modules/cabana/README.md | 6 +- modules/cabana/actions.go | 199 +++++++++++++++++++++ modules/cabana/admin_openapi.go | 38 ++++ modules/cabana/contracts.go | 4 + modules/cabana/extension.go | 127 +++++++++++++ modules/cabana/form_schema.go | 58 ++++++ modules/cabana/http.go | 5 + modules/cabana/messages.go | 13 ++ modules/cabana/openapi_conformance_test.go | 39 +++- modules/cabana/phase10_coverage_test.go | 3 +- modules/cabana/phase10_csrf_test.go | 7 +- modules/cabana/registry.go | 8 + modules/cabana/schema_types.go | 10 ++ modules/cabana/security_coverage_test.go | 2 + modules/cabana/settings.go | 6 + modules/pact/README.md | 7 + modules/pact/capabilities.go | 64 +++++++ 19 files changed, 876 insertions(+), 10 deletions(-) create mode 100644 modules/cabana/actions.go create mode 100644 modules/cabana/extension.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 73dac7b..670cf16 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1,6 +1,34 @@ { "components": { "schemas": { + "cabana.AdminActionRequest": { + "properties": { + "record_id": { + "type": "integer" + }, + "values": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, + "cabana.AdminActionResult": { + "properties": { + "fill": { + "additionalProperties": {}, + "type": "object" + }, + "message": { + "type": "string" + } + }, + "required": [ + "fill", + "message" + ], + "type": "object" + }, "cabana.AdminIDsRequest": { "properties": { "ids": { @@ -196,6 +224,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_AdminActionResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.AdminActionResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_AdminLoginData": { "properties": { "data": { @@ -394,6 +437,14 @@ }, "cabana.FormField": { "properties": { + "action": { + "description": "Action names the controller action the widget runs (pact.HasAdminActions).", + "type": "string" + }, + "actionLabel": { + "description": "ActionLabel is the action's Label, localized per request.", + "type": "string" + }, "attributes": { "additionalProperties": { "$ref": "#/components/schemas/cabana.jsonScalar" @@ -412,6 +463,13 @@ "emptyOption": { "type": "string" }, + "fill": { + "description": "Fill lists the fields of the same form the action writes back (D-07).", + "items": { + "type": "string" + }, + "type": "array" + }, "label": { "type": "string" }, @@ -450,6 +508,10 @@ }, "type": { "type": "string" + }, + "widget": { + "description": "Widget is the custom-element tag of a `type: widget` field (D-06).", + "type": "string" } }, "required": [ @@ -2668,6 +2730,121 @@ ] } }, + "/{vendor}/{plugin}/{controller}/widgets/{field}": { + "post": { + "description": "Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response.", + "parameters": [ + { + "description": "Vendor", + "in": "path", + "name": "vendor", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plugin", + "in": "path", + "name": "plugin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Controller", + "in": "path", + "name": "controller", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Widget field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminActionRequest" + } + } + }, + "description": "Record id and fill snapshot", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Run a widget action", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}": { "delete": { "parameters": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 243b32a..f4309f9 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -1237,6 +1237,95 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/widgets/{field}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Run a widget action + * @description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Widget field name */ + field: string; + }; + cookie?: never; + }; + /** @description Record id and fill snapshot */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminActionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminActionResult"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}": { parameters: { query?: never; @@ -1812,6 +1901,18 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + "cabana.AdminActionRequest": { + record_id?: number; + values?: { + [key: string]: unknown; + }; + }; + "cabana.AdminActionResult": { + fill: { + [key: string]: unknown; + }; + message: string; + }; "cabana.AdminIDsRequest": { ids: number[]; }; @@ -1864,6 +1965,10 @@ export interface components { data: components["schemas"]["cabana.SettingsEntry"][]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_AdminActionResult": { + data: components["schemas"]["cabana.AdminActionResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_AdminLoginData": { data: components["schemas"]["cabana.AdminLoginData"]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -1919,6 +2024,10 @@ export interface components { value: string; }; "cabana.FormField": { + /** @description Action names the controller action the widget runs (pact.HasAdminActions). */ + action?: string; + /** @description ActionLabel is the action's Label, localized per request. */ + actionLabel?: string; attributes?: { [key: string]: components["schemas"]["cabana.jsonScalar"]; }; @@ -1926,6 +2035,8 @@ export interface components { context?: components["schemas"]["cabana.fieldContext"]; default?: components["schemas"]["cabana.jsonScalar"]; emptyOption?: string; + /** @description Fill lists the fields of the same form the action writes back (D-07). */ + fill?: string[]; label?: string; multiple?: boolean; name: string; @@ -1938,6 +2049,8 @@ export interface components { span?: string; tab?: string; type: string; + /** @description Widget is the custom-element tag of a `type: widget` field (D-06). */ + widget?: string; }; "cabana.FormMessages": { create: components["schemas"]["cabana.MessageForms"]; diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 5ff39c1..25b87fe 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -14,6 +14,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly. - Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md), and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write. - Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. +- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys with scalar values. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix. - 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`, 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. @@ -36,6 +37,7 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. | | GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. | | POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. | +| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. | | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | | POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | @@ -109,7 +111,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.Allows` | Checks a principal against required permission codes. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | -| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. | +| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | +| `cabana.AdminActionRequest` | Body of an action route: optional `record_id` and the widget's `values`. Unknown keys are refused. | +| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. | ## Configuration diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go new file mode 100644 index 0000000..b2d6b6e --- /dev/null +++ b/modules/cabana/actions.go @@ -0,0 +1,199 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "reflect" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/towel" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the +// SPA posts on behalf of a `type: widget` field, cabana checks the controller +// and action permissions, loads record_id through the controller's form scope +// and runs the registered action. Only the field's declared fill keys with +// scalar values reach the action and the response. +func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + field, ok := widgetField(cc, r.PathValue("field")) + if !ok { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + action, ok := cc.Actions[field.Action] + if !ok { + WriteError(w, http.StatusNotFound, "not_found", msgNotFound) + return + } + if !s.allowAction(w, r, action) { + return + } + in, err := decodeActionRequest(r) + if err != nil { + writeCRUDError(w, err) + return + } + input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)} + if in.RecordID != nil { + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + record, err := readScopedRecord(r.Context(), db, cc, *in.RecordID) + if err != nil { + writeCRUDError(w, err) + return + } + id := *in.RecordID + input.RecordID = &id + input.Record = record + } + s.runAction(w, r, cc, action, input, field.Fill) + }) +} + +// allowAction applies the action's own permissions on top of the controller's +// (already checked by protect). A denial is logged and answered 403. +func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool { + principal, _ := bouncer.User(r.Context()) + if Allows(principal, action.Permissions) { + return true + } + var adminID uint + if principal != nil { + adminID = principal.ID + } + s.logAuth(r, "denied", adminID) + WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) + return false +} + +// runAction calls the plugin's Run and writes the D-10 envelope. A +// *ValidationError is a 422; any other error is logged and answered with the +// generic 500 body, never the error text. +func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) { + tr := s.translator() + ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr)) + result, err := action.Run(ctx, input) + if err != nil { + var invalid *ValidationError + if errors.As(err, &invalid) { + writeCRUDError(w, err) + return + } + slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err) + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + WriteData(w, http.StatusOK, AdminActionResult{ + Message: translateKey(ctx, tr, result.Message), + Fill: onlyFillScalars(fill, result.Fill), + }, nil) +} + +// widgetField returns the form's `type: widget` field with the given name. +func widgetField(cc *CompiledController, name string) (FormField, bool) { + if cc == nil || cc.Form == nil || name == "" { + return FormField{}, false + } + for _, field := range cc.Form.Fields { + if field.Name == name && field.Type == "widget" { + return field, true + } + } + return FormField{}, false +} + +// decodeActionRequest decodes the strict {record_id, values} body: unknown +// keys, a malformed body or trailing tokens are a validation failure (422). +func decodeActionRequest(r *http.Request) (AdminActionRequest, error) { + invalid := &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}} + dec := json.NewDecoder(r.Body) + dec.UseNumber() + dec.DisallowUnknownFields() + var in AdminActionRequest + if err := dec.Decode(&in); err != nil { + return AdminActionRequest{}, invalid + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + return AdminActionRequest{}, invalid + } + return in, nil +} + +// readScopedRecord loads one record through the controller's FormExtendQuery +// scope, exactly as show and update do, but without a row lock: it is a read +// outside any write transaction. Missing and out-of-scope ids are both +// recordNotFound (404). +func readScopedRecord(ctx context.Context, db *gorm.DB, cc *CompiledController, id uint64) (any, error) { + model, err := newWritableModel(cc) + if err != nil { + return nil, err + } + pk, err := coercePK(model, id) + if err != nil { + return nil, recordNotFound{} + } + q := db.WithContext(ctx) + if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil { + if next := ext.FormExtendQuery(ctx, q); next != nil { + q = next + } + } + err = q.Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: pk}).Take(model).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, recordNotFound{} + } + if err != nil { + return nil, lifecycleFailure(cc, err) + } + return model, nil +} + +// onlyFillScalars keeps the keys named in fill whose values are JSON scalars +// or null. The result is never nil. +func onlyFillScalars(fill []string, values map[string]any) map[string]any { + out := map[string]any{} + for _, key := range fill { + value, ok := values[key] + if !ok || !isJSONScalar(value) { + continue + } + out[key] = value + } + return out +} + +// isJSONScalar reports whether v encodes as a JSON string, number, boolean or +// null. +func isJSONScalar(v any) bool { + if v == nil || nestedValue(v) { + return v == nil + } + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return true + } + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Bool, reflect.String, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + default: + return false + } +} diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 00ce7c7..28d093f 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -387,6 +387,44 @@ func AdminCreate() {} // @Router /{vendor}/{plugin}/{controller}/bulk-delete [post] func AdminBulkDelete() {} +// AdminActionRequest is the body of a widget or toolbar action. record_id is +// the record a widget on the update form belongs to (absent on create and +// always absent for a toolbar action); values is the widget's snapshot of its +// fill fields. +type AdminActionRequest struct { + RecordID *uint64 `json:"record_id,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +// AdminActionResult is an action's answer: a localized message for the toast +// and the widget write-back, holding only the field's declared fill keys with +// scalar values. fill is always an object. +type AdminActionResult struct { + Message string `json:"message"` + Fill map[string]any `json:"fill"` +} + +// AdminWidgetAction documents the widget action route. +// +// @Summary Run a widget action +// @Description Runs the controller action a `type: widget` field declares. The record is loaded through the controller's form scope (404 when out of scope); only the field's fill keys with scalar values reach the action and the response. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param field path string true "Widget field name" +// @Param body body AdminActionRequest true "Record id and fill snapshot" +// @Success 200 {object} Envelope[AdminActionResult] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/widgets/{field} [post] +func AdminWidgetAction() {} + // AdminShow documents the record show route. // // @Summary Show an admin record diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index b3f23b3..a52c4da 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -73,6 +73,10 @@ type CompiledController struct { Writable []WritableField // FieldRelations are the form's `type: relation` fields keyed by field name. FieldRelations map[string]*CompiledFieldRelation + // Actions are the controller's pact.HasAdminActions entries keyed by name: + // the single namespace that toolbar.buttons names and widget action: keys + // resolve through. create and delete are reserved built-in names. + Actions map[string]pact.AdminAction } // Registry is the immutable controller map keyed by controller ID. diff --git a/modules/cabana/extension.go b/modules/cabana/extension.go new file mode 100644 index 0000000..d9214db --- /dev/null +++ b/modules/cabana/extension.go @@ -0,0 +1,127 @@ +package cabana + +import ( + "fmt" + "io/fs" + "regexp" + "strings" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// widgetTagPattern is a valid custom-element name restricted to lowercase +// ASCII: a letter, then at least one hyphenated segment. +var widgetTagPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)+$`) + +// reservedWidgetTags are the hyphenated names the HTML specification reserves; +// customElements.define refuses them. +var reservedWidgetTags = map[string]bool{ + "annotation-xml": true, "color-profile": true, "font-face": true, "font-face-src": true, + "font-face-uri": true, "font-face-format": true, "font-face-name": true, "missing-glyph": true, +} + +// builtinToolbarActions are the toolbar actions the framework implements +// itself (D-14); a controller may not register an action with these names. +var builtinToolbarActions = map[string]bool{"create": true, "delete": true} + +// widgetTagPrefix is the custom-element prefix a plugin's widgets must use: +// the plugin ID lowercased with dots and underscores turned into hyphens, +// plus a trailing hyphen (acme.conform -> "acme-conform-"). It keeps two +// plugins from defining the same element. +func widgetTagPrefix(pluginID string) string { + return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-" +} + +// compileExtension validates a controller's runtime admin extension points +// after its list and form are compiled and its writable fields are bound: the +// registered actions and every `type: widget` field. Every failure stops boot. +func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error { + if cc == nil || cc.Controller == nil { + return nil + } + id := cc.Controller.ID() + actions, err := compileActions(cc.Controller) + if err != nil { + return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err) + } + cc.Actions = actions + if cc.Form == nil { + return nil + } + file := cc.Form.fieldsPath + if file == "" { + file = "fields.yaml" + } + fields := map[string]FormField{} + for _, field := range cc.Form.Fields { + fields[field.Name] = field + } + writable := map[string]bool{} + for _, field := range cc.Writable { + writable[field.Name] = true + } + prefix := widgetTagPrefix(pluginID) + for i := range cc.Form.Fields { + field := &cc.Form.Fields[i] + if field.Type != "widget" { + continue + } + if err := checkWidgetTag(field.Widget, prefix); err != nil { + return bootErr(pluginID, id, file, fmt.Errorf("field %s: %w", field.Name, err)) + } + action, ok := actions[field.Action] + if !ok { + return bootErr(pluginID, id, file, fmt.Errorf("field %s: action %s is not registered by the controller (pact.HasAdminActions)", field.Name, field.Action)) + } + for _, key := range field.Fill { + target, exists := fields[key] + if !exists { + return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a field of this form", field.Name, key)) + } + if !scalarFormField(target.Type) || !writable[key] { + return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a writable scalar field", field.Name, key)) + } + } + field.ActionLabel = action.Label + } + return nil +} + +func checkWidgetTag(tag, prefix string) error { + if !widgetTagPattern.MatchString(tag) { + return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", tag) + } + if reservedWidgetTags[tag] { + return fmt.Errorf("widget %q is a reserved element name", tag) + } + if !strings.HasPrefix(tag, prefix) { + return fmt.Errorf("widget %q must start with the plugin prefix %q", tag, prefix) + } + return nil +} + +// compileActions collects a controller's registered actions into the single +// action namespace (assumption-delta decision: create and delete are reserved). +func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, error) { + out := map[string]pact.AdminAction{} + src, ok := ctl.(pact.HasAdminActions) + if !ok || src == nil { + return out, nil + } + for _, action := range src.AdminActions() { + if !identifier(action.Name) { + return nil, fmt.Errorf("action name %q is not an identifier", action.Name) + } + if builtinToolbarActions[action.Name] { + return nil, fmt.Errorf("action %s uses a reserved built-in name (create, delete)", action.Name) + } + if _, dup := out[action.Name]; dup { + return nil, fmt.Errorf("duplicate action %s", action.Name) + } + if action.Run == nil { + return nil, fmt.Errorf("action %s has no Run function", action.Name) + } + out[action.Name] = action + } + return out, nil +} diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index 4bf17d6..72594e3 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -23,6 +23,7 @@ var ( formFieldTypes = map[string]struct{}{ "text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, + "widget": {}, } formSpans = map[string]struct{}{ "left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, @@ -34,7 +35,10 @@ var ( "label": {}, "comment": {}, "span": {}, "type": {}, "required": {}, "tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {}, "nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {}, + "widget": {}, "action": {}, "fill": {}, } + // widgetKeys are valid only on `type: widget` (D-06). + widgetKeys = []string{"widget", "action", "fill"} ) type formConfigDocument struct { @@ -108,6 +112,7 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc ModelClass: doc.ModelClass, Fields: fields, redirects: FormRedirects{Default: doc.DefaultRedirect}, + fieldsPath: fieldsPath, } if doc.Messages != nil { schema.messageKeys = *doc.Messages @@ -139,6 +144,8 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr field.Comment = translateKey(ctx, tr, src.Comment) field.Tab = translateKey(ctx, tr, src.Tab) field.EmptyOption = translateKey(ctx, tr, src.EmptyOption) + field.ActionLabel = translateKey(ctx, tr, src.ActionLabel) + field.Fill = append([]string(nil), src.Fill...) options, err := localizeOptions(ctx, tr, src, provider) if err != nil { return nil, err @@ -412,6 +419,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) { if typ == "relation-manager" && field.Relation == "" { return FormField{}, fmt.Errorf("relation is required") } + if err := compileWidgetKeys(typ, values, &field); err != nil { + return FormField{}, err + } if node, ok := values["required"]; ok { field.Required, err = nodeBool(node) if err != nil { @@ -440,6 +450,54 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) { return field, nil } +// compileWidgetKeys decodes the D-06 widget keys. They are refused on any +// other type; a widget needs a custom-element tag and an action name, and may +// list the fields its action writes back. The tag prefix, the registered +// action and the fill fields are checked against the controller in +// compileExtension. +func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error { + if typ != "widget" { + for _, key := range widgetKeys { + if _, ok := values[key]; ok { + return fmt.Errorf("%s is only valid on type: widget", key) + } + } + return nil + } + tag, err := nodeString(values["widget"]) + if err != nil || strings.TrimSpace(tag) == "" { + return fmt.Errorf("widget (the custom-element tag) is required on type: widget") + } + field.Widget = tag + action, err := nodeString(values["action"]) + if err != nil || !identifier(action) { + return fmt.Errorf("action %q is not an identifier (type: widget needs a registered action)", nodeText(values["action"])) + } + field.Action = action + if node, ok := values["fill"]; ok { + seq, ok := node.(*ast.SequenceNode) + if !ok { + return fmt.Errorf("fill must be a list of field names") + } + items := sequenceValues(seq) + fill := make([]string, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + name, err := nodeString(unwrapNode(item)) + if err != nil || !identifier(name) { + return fmt.Errorf("fill %q is not a field name", nodeText(item)) + } + if _, dup := seen[name]; dup { + return fmt.Errorf("fill: duplicate field %s", name) + } + seen[name] = struct{}{} + fill = append(fill, name) + } + field.Fill = fill + } + return nil +} + func compileContext(node ast.Node) (*fieldContext, error) { switch n := node.(type) { case *ast.StringNode: diff --git a/modules/cabana/http.go b/modules/cabana/http.go index bca3ac3..613ec6d 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -218,6 +218,11 @@ func (s *service) mount(r pact.Router) { constrainController(g) g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete)) constrainController(g) + // Runtime extension actions (Phase 10.1): cabana owns these routes, so + // CSRF, auth and record scoping never depend on plugin code. + g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction)) + constrainController(g) + g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show) constrainController(g) g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update)) diff --git a/modules/cabana/messages.go b/modules/cabana/messages.go index b73ee05..2240206 100644 --- a/modules/cabana/messages.go +++ b/modules/cabana/messages.go @@ -6,6 +6,7 @@ import ( "fmt" "reflect" "sort" + "strings" "git.golem15.com/golem15/summercms/modules/phrasebook" ) @@ -240,6 +241,18 @@ func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error { return err } } + actionNames := make([]string, 0, len(cc.Actions)) + for name := range cc.Actions { + actionNames = append(actionNames, name) + } + sort.Strings(actionNames) + for _, name := range actionNames { + // A label with a namespace separator is a phrase key and must + // resolve; anything else is literal button text. + if label := cc.Actions[name].Label; strings.Contains(label, "::") && !tr.Has(label) { + return fmt.Errorf("cabana: admin controller %s/%s: action %s label names missing phrase key %s", cc.PluginID, id, name, label) + } + } names := make([]string, 0, len(cc.Relations)) for name := range cc.Relations { names = append(names, name) diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index c450533..665fd96 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -2,6 +2,7 @@ package cabana_test import ( "bytes" + "context" "encoding/json" "fmt" "io/fs" @@ -103,6 +104,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) { e.gadgetID = dataID(t, rec.Body.Bytes()) return rec }, into[cabana.RecordEnvelope]()}, + {"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true) + // The fixture action also returns active, which is outside the + // field's fill: the server filter must drop it. + var body cabana.Envelope[cabana.AdminActionResult] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("widget action body: %v\n%s", err, rec.Body.String()) + } + if len(body.Data.Fill) != 1 || body.Data.Fill["name"] != "lookup-"+e.stamp { + t.Fatalf("widget fill = %#v, want only name", body.Data.Fill) + } + return rec + }, into[cabana.Envelope[cabana.AdminActionResult]]()}, {"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true) }, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()}, @@ -299,7 +313,7 @@ func newConformEnv(t *testing.T) *conformEnv { if err := lagoon.Publish(app, adminSQL, gdb); err != nil { t.Fatal(err) } - plugins := []party.Plugin{conformPlugin{}} + plugins := []party.Plugin{conformPlugin{stamp: stamp}} if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } @@ -362,14 +376,14 @@ func (conformSettings) TableName() string { return "cabana_conform_settin func (conformSettings) Fillable() []string { return []string{"enabled"} } func (conformSettings) Rules() map[string]string { return map[string]string{} } -type conformPlugin struct{} +type conformPlugin struct{ stamp string } func (conformPlugin) ID() string { return "acme.conform" } func (conformPlugin) Requires() []string { return nil } func (conformPlugin) Register(*backpack.App) error { return nil } func (conformPlugin) Boot(*backpack.App) error { return nil } -func (conformPlugin) AdminControllers() []pact.AdminController { - return []pact.AdminController{conformController{}} +func (p conformPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{conformController{stamp: p.stamp}} } func (conformPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}} @@ -386,7 +400,7 @@ func (conformPlugin) Settings() []pact.SettingsItem { } func (conformPlugin) AdminFS() fs.FS { return conformFS() } -type conformController struct{} +type conformController struct{ stamp string } func (conformController) ID() string { return "acme.conform.gadgets" } func (conformController) ModelName() string { return "Gadget" } @@ -403,6 +417,15 @@ func (conformController) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}} } +func (c conformController) AdminActions() []pact.AdminAction { + return []pact.AdminAction{{ + Name: "lookup", Label: "Look up", Permissions: []string{"acme.conform.access"}, + Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) { + return pact.AdminActionResult{Message: "Looked up", Fill: map[string]any{"name": "lookup-" + c.stamp, "active": true}}, nil + }, + }} +} + func conformFS() fs.FS { file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} } return fstest.MapFS{ @@ -483,6 +506,12 @@ update: type: relation-manager relation: members context: [update] + lookup: + label: Lookup + type: widget + widget: acme-conform-lookup + action: lookup + fill: [name] `), "models/settings/fields.yaml": file(`fields: enabled: diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index 81e4c44..e9bb3e1 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -94,13 +94,14 @@ func TestPhase10Coverage(t *testing.T) { "POST /auth/refresh", "POST /{vendor}/{plugin}/{controller}", "POST /{vendor}/{plugin}/{controller}/bulk-delete", + "POST /{vendor}/{plugin}/{controller}/widgets/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "PUT /settings/{code}", "PUT /{vendor}/{plugin}/{controller}/{id}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 9 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 10 besides login):\n%s", strings.Join(unsafe, "\n")) } // The routes added in Phase 10 are safe reads: GET /lang and the shared // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index d8dcd8d..70c5b4c 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -66,9 +66,10 @@ func TestPhase10CSRF(t *testing.T) { } }) } - // refresh, logout, settings put, create, bulk-delete, update, delete, link, unlink - if unsafe != 9 { - t.Fatalf("walked %d state-changing routes, want 9: %v", unsafe, router.order) + // refresh, logout, settings put, create, bulk-delete, widget action, + // update, delete, link, unlink + if unsafe != 10 { + t.Fatalf("walked %d state-changing routes, want 10: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/registry.go b/modules/cabana/registry.go index 7b863eb..bcc004d 100644 --- a/modules/cabana/registry.go +++ b/modules/cabana/registry.go @@ -92,6 +92,9 @@ func compileRegistry(items []controllerRef) (*Registry, error) { if err := BindWritableFields(compiled); err != nil { return nil, err } + if err := compileExtension(item.plugin.ID(), compiled, assets.AdminFS()); err != nil { + return nil, err + } byID[id] = compiled } return &Registry{byID: byID}, nil @@ -179,6 +182,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error { return err } } + for name, action := range controller.Actions { + if err := reg.validatePermissions("action "+id+"."+name, action.Permissions); err != nil { + return err + } + } } for _, item := range reg.navigation { if err := reg.validateNavigation(item); err != nil { diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index 7c3b77c..1c5169b 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -118,6 +118,8 @@ type FormSchema struct { messageKeys formMessageKeys redirects FormRedirects + // fieldsPath is the fields.yaml the form was compiled from, for boot errors. + fieldsPath string } // FormView is one request's localized form, including the locale actually used. @@ -170,6 +172,14 @@ type FormField struct { Default *jsonScalar `json:"default,omitempty"` Attributes map[string]jsonScalar `json:"attributes,omitempty"` Options []FormOption `json:"options,omitempty"` + // Widget is the custom-element tag of a `type: widget` field (D-06). + Widget string `json:"widget,omitempty"` + // Action names the controller action the widget runs (pact.HasAdminActions). + Action string `json:"action,omitempty"` + // ActionLabel is the action's Label, localized per request. + ActionLabel string `json:"actionLabel,omitempty"` + // Fill lists the fields of the same form the action writes back (D-07). + Fill []string `json:"fill,omitempty"` optionsMethod string } diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index 9296f0c..7635d52 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -50,6 +50,7 @@ var phase09Routes = []adminRoute{ {key: "GET /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"}, + {key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}"}, {key: "PUT /{vendor}/{plugin}/{controller}/{id}"}, {key: "DELETE /{vendor}/{plugin}/{controller}/{id}"}, @@ -264,6 +265,7 @@ func phase09ProtectedCalls() []phase09Call { {"list", (*service).list}, {"create", (*service).create}, {"bulk-delete", (*service).bulkDelete}, + {"widget-action", (*service).widgetAction}, {"show", (*service).show}, {"update", (*service).update}, {"delete", (*service).deleteRecord}, diff --git a/modules/cabana/settings.go b/modules/cabana/settings.go index 24d5768..df2a539 100644 --- a/modules/cabana/settings.go +++ b/modules/cabana/settings.go @@ -61,6 +61,12 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi if err != nil { return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err) } + for _, field := range fields { + // A settings screen has no admin controller to own actions or view models. + if field.Type == "widget" { + return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type) + } + } form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields} columns := modelColumns(model) fillable := map[string]struct{}{} diff --git a/modules/pact/README.md b/modules/pact/README.md index e861d90..4c1684a 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -14,6 +14,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf - 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`. - 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), 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`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library. - `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package. @@ -83,6 +84,12 @@ func showPost(w http.ResponseWriter, r *http.Request) {} | `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. | +| `pact.AdminClientAssets` | Declares a controller's admin JS (`AdminJS`) and CSS (`AdminCSS`) files, paths under the plugin's `assets/` directory. | +| `pact.AdminAction` | One named controller action: name, label, extra permissions and the Go `Run` function. | +| `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. | +| `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. | +| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. | +| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | | `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. | | `pact.Option` | One dropdown choice (value and label). | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 9bdf93f..3ac8a80 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -129,6 +129,70 @@ type AdminRecordSource interface { NewRecord() any } +// AdminClientAssets is Winter's addJs/addCss for one admin controller. The +// paths are relative to the owning plugin's AdminFS and must live under +// assets/ (for example assets/js/lookup.js). The admin SPA loads them when the +// controller's list or form opens; files are always served from the embedded +// tree, never from disk. It is separate from AdminAssets, which is the YAML +// tree itself. +type AdminClientAssets interface { + AdminJS() []string + AdminCSS() []string +} + +// AdminAction is one controller action that a list toolbar button or a form +// widget runs. The admin framework owns the HTTP route, the CSRF check, +// authentication and record scoping; Run only carries the business logic. +// Name is an identifier unique within the controller; create and delete are +// reserved for the built-in toolbar actions. Label is a phrase key or literal +// text used as the button caption. Permissions are checked in addition to the +// controller's RequiredPermissions. +type AdminAction struct { + Name string + Label string + Permissions []string + Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"` +} + +// AdminActionInput is what the framework hands an AdminAction. Field is the +// widget field name and is empty for a toolbar action. RecordID is nil on the +// create form and always nil for a toolbar action: toolbar actions carry no +// record ids, so an id list can never become an unscoped lookup. Record is the +// record the framework loaded through the controller's FormExtendQuery scope, +// nil when RecordID is nil. Values is the widget's snapshot of its fill +// fields, already reduced to the field's declared fill keys and to scalars. +type AdminActionInput struct { + Field string + RecordID *uint64 + Record any + Values map[string]any +} + +// AdminActionResult is an action's answer. Message is a phrase key or text, +// localized by the framework and shown as a toast. Fill is the widget +// write-back; keys outside the field's declared fill keys and non-scalar +// values are dropped before the response is written. +type AdminActionResult struct { + Message string + Fill map[string]any +} + +// HasAdminActions is implemented by an admin controller that registers named +// actions for its toolbar.buttons list and its `type: widget` form fields. +type HasAdminActions interface { + AdminActions() []AdminAction +} + +// 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 +// existing record, else nil. The result must be a curated view model built +// for the template, never the GORM model itself: the framework refuses a +// value of the controller's model type. +type AdminPartialData interface { + PartialData(ctx context.Context, name string, record any) (any, error) +} + // Permission is one registerPermissions() entry. type Permission struct { Code string