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

@@ -178,6 +178,30 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
return out, nil
}
// rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked"
// rosterRefused is a shared refusal value: the framework must localize a
// copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
// the field, and fails with a plain error for the name Boom.
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
switch model.(*rosterPerson).Name {
case "Reserved":
return &cabana.ForbiddenError{
Message: "acme.roster::lang.people.refused",
Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}},
}
case "Silent":
return &cabana.ForbiddenError{}
case "Boom":
return fmt.Errorf("the roster database said hunter2")
}
return nil
}
// FormAfterDelete removes the person for good inside the delete's
// transaction: the list keeps soft-deleted people, so deleting one there is
// permanent.
@@ -226,6 +250,11 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
for _, record := range in.Records {
// A refusal after earlier rows were written: the whole
// selection must roll back.
if record.(*rosterPerson).Name == rosterLocked {
return pact.AdminBulkActionResult{}, rosterRefused
}
if err := tx.Delete(record).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
@@ -270,6 +299,10 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
// Refused after the write: the transaction must roll it back.
if in.Record.(*rosterPerson).Name == rosterLocked {
return pact.AdminRecordActionResult{}, rosterRefused
}
return pact.AdminRecordActionResult{}, nil
},
}}