pact.SettingsItem gains an additive Controller field, the equivalent of a WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry declares no Model, Form or NewModel and needs no AdminFS; start-up fails when it combines Controller with a singleton form or a model, or names an unregistered controller. Settings codes stay one namespace. GET /settings lists a link entry with its controller only when the principal passes the item's permissions and may open the controller. Registry.Setting never returns a link entry, so the singleton settings endpoints answer 404 for its code. SettingsEntry carries controller, empty for singletons; the OpenAPI document, generated SPA types and settings fixture follow, and the pact and cabana READMEs and the settings docs describe the link.
133 lines
4.8 KiB
Go
133 lines
4.8 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
)
|
|
|
|
// NavigationEntry is one permission-filtered backend navigation item.
|
|
type NavigationEntry struct {
|
|
Code string `json:"code"`
|
|
Label string `json:"label"`
|
|
Icon string `json:"icon"`
|
|
Order int `json:"order"`
|
|
Controller string `json:"controller"`
|
|
SideMenu []NavigationEntry `json:"sideMenu"`
|
|
}
|
|
|
|
// SettingsEntry is one permission-filtered settings-list item.
|
|
type SettingsEntry struct {
|
|
Code string `json:"code"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
Category string `json:"category"`
|
|
Icon string `json:"icon"`
|
|
Order int `json:"order"`
|
|
Keywords []string `json:"keywords"`
|
|
Model string `json:"model"`
|
|
// Controller is the admin controller a link entry opens; empty for a
|
|
// singleton settings form.
|
|
Controller string `json:"controller"`
|
|
}
|
|
|
|
// Metadata returns only entries the backend principal may open. Denied
|
|
// entries are removed before any response value is constructed.
|
|
func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, tr *phrasebook.Translator) ([]NavigationEntry, []SettingsEntry) {
|
|
navigation := make([]NavigationEntry, 0)
|
|
settings := make([]SettingsEntry, 0)
|
|
if r == nil || principal == nil || !principal.Backend {
|
|
return navigation, settings
|
|
}
|
|
for _, item := range r.navigation {
|
|
// Like Winter's NavigationManager, a main item the principal may not
|
|
// open is dropped whatever its children allow, so a denied parent
|
|
// never leaks its label or target controller.
|
|
if !Allows(principal, item.Permissions) {
|
|
continue
|
|
}
|
|
children := make([]NavigationEntry, 0)
|
|
for _, child := range item.SideMenu {
|
|
if !Allows(principal, child.Permissions) {
|
|
continue
|
|
}
|
|
children = append(children, navigationView(ctx, tr, child, nil))
|
|
}
|
|
view := navigationView(ctx, tr, item, children)
|
|
view.Controller = r.openableTarget(principal, item, children)
|
|
navigation = append(navigation, view)
|
|
}
|
|
sort.SliceStable(navigation, func(i, j int) bool {
|
|
if navigation[i].Order == navigation[j].Order {
|
|
return navigation[i].Code < navigation[j].Code
|
|
}
|
|
return navigation[i].Order < navigation[j].Order
|
|
})
|
|
for _, compiled := range r.settings {
|
|
item := compiled.Item
|
|
// A link entry is listed only when the principal can also open its
|
|
// controller, so a denied controller ID never leaks.
|
|
if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) {
|
|
continue
|
|
}
|
|
keywords := append([]string(nil), item.Keywords...)
|
|
if keywords == nil {
|
|
keywords = []string{}
|
|
}
|
|
settings = append(settings, SettingsEntry{
|
|
Code: item.Code, Label: translateKey(ctx, tr, item.Label),
|
|
Description: translateKey(ctx, tr, item.Description),
|
|
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
|
|
Order: item.Order, Keywords: keywords, Model: item.Model,
|
|
Controller: item.Controller,
|
|
})
|
|
}
|
|
sort.SliceStable(settings, func(i, j int) bool {
|
|
if settings[i].Order == settings[j].Order {
|
|
return settings[i].Code < settings[j].Code
|
|
}
|
|
return settings[i].Order < settings[j].Order
|
|
})
|
|
return navigation, settings
|
|
}
|
|
|
|
// openableTarget returns the controller a main item should link to. It is the
|
|
// item's own controller unless the principal cannot open it, in which case it
|
|
// is the first side-menu entry the principal can, so the menu never links to a
|
|
// page that answers 403. It is empty when nothing is openable.
|
|
func (r *Registry) openableTarget(principal *bouncer.Principal, item pact.NavigationItem, children []NavigationEntry) string {
|
|
if item.Controller == "" || r.canOpen(principal, item.Controller) {
|
|
return item.Controller
|
|
}
|
|
for _, child := range children {
|
|
if child.Controller != "" && r.canOpen(principal, child.Controller) {
|
|
return child.Controller
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// canOpen reports whether principal passes a registered controller's required
|
|
// permissions. A controller the registry does not know is not blocked here:
|
|
// the guard answers for it when it is opened.
|
|
func (r *Registry) canOpen(principal *bouncer.Principal, controller string) bool {
|
|
cc, ok := r.Get(controller)
|
|
if !ok {
|
|
return true
|
|
}
|
|
return Allows(principal, requiredOf(cc.Controller))
|
|
}
|
|
|
|
func navigationView(ctx context.Context, tr *phrasebook.Translator, item pact.NavigationItem, children []NavigationEntry) NavigationEntry {
|
|
if children == nil {
|
|
children = []NavigationEntry{}
|
|
}
|
|
return NavigationEntry{
|
|
Code: item.Code, Label: translateKey(ctx, tr, item.Label), Icon: item.Icon,
|
|
Order: item.Order, Controller: item.Controller, SideMenu: children,
|
|
}
|
|
}
|