feat(12.1-01): cabana.ForbiddenError answers a refused write with 403

- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:53:34 +02:00
parent 61d5fc72ad
commit 71073bc8a2
23 changed files with 486 additions and 48 deletions

View File

@@ -255,8 +255,8 @@ func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permission
}
// 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.
// *ValidationError is a 422 and a *ForbiddenError a 403; 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))
@@ -267,6 +267,12 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled
writeCRUDError(w, err)
return
}
// A refusal (D-27) is a 403 with the action's localized message.
var refused *ForbiddenError
if errors.As(err, &refused) {
writeCRUDError(w, localizeForbidden(ctx, tr, 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