Files
summercms/modules/cabana/account_contributions.go
Jakub Zych 9f14cf603c feat(19.7-01): account contributions, prompted record actions and backend subject events
- pact.HasAccountContributions compiled at Activate; GET /account/contributions
  and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
  (AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
  Prompt; the record action route accepts payload only for such an action
  (trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
  Value through CRUDService.RecordActionWithValue; a prompt without a label
  fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
  saves that changed role, permissions or superuser, Roles saves that changed
  grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
  TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
  OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
  README, docs/backend/{admin-controllers,users-and-permissions}.md
2026-10-22 16:31:29 +02:00

215 lines
7.7 KiB
Go

package cabana
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// accountContributionCatalog holds the My account sections plugins
// contribute (pact.HasAccountContributions), in activation order.
type accountContributionCatalog struct {
ordered []compiledAccountContribution
byName map[string]*compiledAccountContribution
}
// compiledAccountContribution is one contributed section with its owner.
type compiledAccountContribution struct {
pluginID string
section pact.AccountContribution
actions map[string]pact.AccountAction
}
// compileAccountContributions collects pact.HasAccountContributions from
// plugins into reg, in activation order. Every failure stops boot with a
// message naming the plugin and the section: a name that is not an
// identifier, a name another plugin already contributed, an empty label, an
// action whose name is not an identifier or repeats within the section, a
// nil Run or an empty action label.
func compileAccountContributions(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")
}
cat := &accountContributionCatalog{byName: map[string]*compiledAccountContribution{}}
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasAccountContributions)
if !ok || src == nil {
continue
}
pluginID := plugin.ID()
for _, section := range src.AccountContributions() {
prefix := fmt.Sprintf("cabana: plugin %s contributes account section %q", pluginID, section.Name)
if !identifier(section.Name) {
return fmt.Errorf("%s: the name is not an identifier", prefix)
}
if taken, ok := cat.byName[section.Name]; ok {
return fmt.Errorf("%s: plugin %s already contributes a section with that name", prefix, taken.pluginID)
}
if strings.TrimSpace(section.Label) == "" {
return fmt.Errorf("%s: it needs a label", prefix)
}
compiled := &compiledAccountContribution{pluginID: pluginID, section: section, actions: map[string]pact.AccountAction{}}
for _, action := range section.Actions {
if !identifier(action.Name) {
return fmt.Errorf("%s: action name %q is not an identifier", prefix, action.Name)
}
if _, dup := compiled.actions[action.Name]; dup {
return fmt.Errorf("%s: duplicate action %q", prefix, action.Name)
}
if action.Run == nil {
return fmt.Errorf("%s: action %q has no Run function", prefix, action.Name)
}
if strings.TrimSpace(action.Label) == "" {
return fmt.Errorf("%s: action %q needs a label", prefix, action.Name)
}
compiled.actions[action.Name] = action
}
cat.ordered = append(cat.ordered, *compiled)
cat.byName[section.Name] = &cat.ordered[len(cat.ordered)-1]
}
}
// The slice may have grown: point the map at the final backing array.
for i := range cat.ordered {
cat.byName[cat.ordered[i].section.Name] = &cat.ordered[i]
}
reg.accountContributions = cat
return nil
}
// accountContributionsList serves GET /account/contributions: every
// contributed section with its status for the signed-in administrator and
// the actions they may run that apply to them, labels translated.
func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
ctx := r.Context()
tr := s.translator()
out := []AccountContributionView{}
if s.reg != nil && s.reg.accountContributions != nil {
for _, item := range s.reg.accountContributions.ordered {
view := AccountContributionView{
Name: item.section.Name,
Label: translateKey(ctx, tr, item.section.Label),
Description: translateKey(ctx, tr, item.section.Description),
Actions: []AccountContributionActionView{},
}
if item.section.Status != nil {
status, err := item.section.Status(ctx, principal.ID)
if err != nil {
moduleLog.ErrorContext(ctx, "cabana: account contribution status failed", "section", item.section.Name, "plugin", item.pluginID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
view.Status = translateKey(ctx, tr, status)
}
for _, action := range item.section.Actions {
if !Allows(principal, action.Permissions) {
continue
}
if action.Applies != nil {
applies, err := action.Applies(ctx, principal.ID)
if err != nil {
moduleLog.ErrorContext(ctx, "cabana: account contribution applies failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
if !applies {
continue
}
}
view.Actions = append(view.Actions, AccountContributionActionView{
Name: action.Name,
Label: translateKey(ctx, tr, action.Label),
Confirm: translateKey(ctx, tr, action.Confirm),
Danger: action.Danger,
})
}
out = append(out, view)
}
}
WriteData(w, http.StatusOK, out, map[string]any{})
}
// accountContributionAction serves POST /account/contributions/{name}/actions/{action}:
// an unknown section or action is 404, a missing permission 403, an action
// that no longer applies 409; then Run runs for the signed-in administrator
// inside a transaction when the admin has a database (the context carries
// it, as for record actions) and its message and URL are answered.
func (s *service) accountContributionAction(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
var item *compiledAccountContribution
if s.reg != nil && s.reg.accountContributions != nil {
item = s.reg.accountContributions.byName[r.PathValue("name")]
}
if item == nil {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
action, ok := item.actions[r.PathValue("action")]
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action.Permissions) {
return
}
tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
run := func(ctx context.Context) (pact.AccountActionResult, error) {
if action.Applies != nil {
applies, err := action.Applies(ctx, principal.ID)
if err != nil {
return pact.AccountActionResult{}, err
}
if !applies {
return pact.AccountActionResult{}, actionConflict{}
}
}
return action.Run(ctx, pact.AccountActionInput{AdminID: principal.ID})
}
var result pact.AccountActionResult
var err error
if db, dbErr := s.db(); dbErr == nil {
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
result, err = run(withTx(ctx, tx))
return err
})
} else {
result, err = run(ctx)
}
if err != nil {
// A refusal the plugin authored (403, 422) or a stale action (409)
// is answered as is; anything else is the plugin's failure, logged
// here and answered as an opaque 500.
var stale actionConflict
var refused *ForbiddenError
var invalid *ValidationError
if !errors.As(err, &stale) && !errors.As(err, &refused) && !errors.As(err, &invalid) {
moduleLog.ErrorContext(ctx, "cabana: account contribution action failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err)
}
writeCRUDError(w, localizeControllerError(ctx, tr, err))
return
}
moduleLog.InfoContext(ctx, "cabana: account contribution action", "admin_id", principal.ID, "section", item.section.Name, "action", action.Name)
WriteData(w, http.StatusOK, AccountActionResultView{
Message: translateKey(ctx, tr, result.Message),
URL: result.URL,
}, nil)
}