Files
summercms/modules/cabana/navigation.go
Jakub Zych c4b1ac4076 feat(cabana): read-only Plugins screen with installed plugins
- GET /plugins (backend.view_plugins, superusers always pass) lists the
  plugins from party.Installed, or party.Activated IDs for older builds,
  with the built framework version and whether the registry is configured
- Settings -> System -> Plugins entry with the new SettingsEntry.path,
  added only for principals allowed to open it
- PluginsView at /system/plugins with an Installed tab (reka-ui Tabs);
  settings panel, rail, breadcrumbs and page title know the route
- OpenAPI and SPA types regenerated; contract, token-class and
  conformance inventories list the route; en/pl strings; docs
2026-10-29 19:05:46 +01:00

177 lines
6.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"`
// Path is the admin SPA route an entry of the admin's own opens (the
// Plugins screen, /system/plugins); omitted for plugin entries.
Path string `json:"path,omitempty"`
}
// Metadata returns only entries the backend principal may open. Denied
// entries are removed before any response value is constructed. A navigation
// entry with a pact.NavigationItem.Visible func is also left out when that
// func answers false for ctx (a main item with its whole side menu, or one
// side item); Visible runs only after the permission check has passed, so a
// denied principal never runs plugin code.
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
}
if item.Visible != nil && !item.Visible(ctx) {
continue
}
children := make([]NavigationEntry, 0)
// hidden collects the controllers of side items a Visible func hid
// for this request, so the parent never links to one of them.
// Permission-denied children are not recorded: the parent keeps its
// own controller whenever the principal can open it.
hidden := map[string]bool{}
for _, child := range item.SideMenu {
if !Allows(principal, child.Permissions) {
continue
}
if child.Visible != nil && !child.Visible(ctx) {
if child.Controller != "" {
hidden[child.Controller] = true
}
continue
}
children = append(children, navigationView(ctx, tr, child, nil))
}
view := navigationView(ctx, tr, item, children)
view.Controller = r.openableTarget(principal, item, children, hidden)
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,
})
}
if r.listsPluginsScreen(principal) {
settings = append(settings, pluginsSettingsEntry(
translateKey(ctx, tr, "backend::lang.plugins.menu_label"),
translateKey(ctx, tr, "backend::lang.plugins.menu_description"),
translateKey(ctx, tr, "backend::lang.settings.category_system"),
))
}
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
}
// listsPluginsScreen reports whether the settings list links the Plugins
// screen for principal: the admin's own screens are compiled (they declare
// PermissionViewPlugins), no plugin took the settings code "plugins", and
// the principal may open the screen.
func (r *Registry) listsPluginsScreen(principal *bouncer.Principal) bool {
if _, declared := r.permissions[PermissionViewPlugins]; !declared {
return false
}
if _, taken := r.settings[pluginsSettingsCode]; taken {
return false
}
return Allows(principal, []string{PermissionViewPlugins})
}
// openableTarget returns the controller a main item should link to. It is the
// item's own controller unless the principal cannot open it or it is the
// controller of a side item hidden for this request (in hidden), in which
// case it is the first of the listed children (already visible and permitted)
// the principal can open, so the menu never links to a page that answers 403
// or to a screen whose feature is off. It is empty when nothing is openable.
func (r *Registry) openableTarget(principal *bouncer.Principal, item pact.NavigationItem, children []NavigationEntry, hidden map[string]bool) string {
if item.Controller == "" || (!hidden[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,
}
}