feat(12.1-01): declared bulk actions on admin lists

- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
  list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:28:30 +02:00
parent ca9e9c0557
commit a879d6388c
45 changed files with 2007 additions and 38 deletions

View File

@@ -32,7 +32,7 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action) {
if !s.allowAction(w, r, action.Permissions) {
return
}
in, err := decodeActionRequest(r)
@@ -82,7 +82,7 @@ func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action) {
if !s.allowAction(w, r, action.Permissions) {
return
}
in, err := decodeActionRequest(r)
@@ -115,11 +115,68 @@ func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, boo
return action, ok
}
// 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 {
// bulkAction serves POST .../{controller}/bulk/{action} (D-09): a registered
// bulk action the list's bulkActions declares. The posted ids are resolved
// through the controller's list scope inside the action's transaction, so the
// plugin receives loaded records and never an id.
func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("action")
action, ok := bulkActionOf(cc, name)
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action.Permissions) {
return
}
in, err := s.decodeCappedBulk(w, r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := svc.BulkAction(r.Context(), cc, name, in)
if err != nil {
writeCRUDError(w, err)
return
}
var adminID uint
if principal, _ := bouncer.User(r.Context()); principal != nil {
adminID = principal.ID
}
slog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected)
WriteData(w, http.StatusOK, result, nil)
})
}
// bulkActionOf returns the registered bulk action a list declares under name
// in bulkActions; the built-in delete is not a declared action.
func bulkActionOf(cc *CompiledController, name string) (pact.AdminBulkAction, bool) {
if cc == nil || cc.List == nil || builtinToolbarActions[name] {
return pact.AdminBulkAction{}, false
}
declared := false
for _, entry := range cc.List.BulkActions {
declared = declared || entry.Name == name
}
if !declared {
return pact.AdminBulkAction{}, false
}
action, ok := cc.BulkActions[name]
return action, ok
}
// allowAction applies an action's own permissions on top of the controller's
// (already checked by protect). Every action kind shares it: a denial is
// logged and answered 403.
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permissions []string) bool {
principal, _ := bouncer.User(r.Context())
if Allows(principal, action.Permissions) {
if Allows(principal, permissions) {
return true
}
var adminID uint