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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user