package pages import ( "context" "fmt" "log" "github.com/gin-gonic/gin" "switchboard-core/store" ) // TeamOption for template dropdowns. type TeamOption struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` MemberCount int `json:"member_count,omitempty"` IsActive bool `json:"is_active"` } // ProviderTypeOption for the provider type dropdown. type ProviderTypeOption struct { ID string `json:"id"` Name string `json:"name"` } // UserRow is a user for the admin users table. type UserRow struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` DisplayName string `json:"display_name"` Role string `json:"role"` IsActive bool `json:"is_active"` } // RoleConfig holds a role's current settings. type RoleConfig struct { Name string `json:"name"` Primary *RoleSelection `json:"primary"` Fallback *RoleSelection `json:"fallback"` } // RoleSelection is provider + model pair for a role slot. type RoleSelection struct { ProviderID string `json:"provider_config_id"` ModelID string `json:"model_id"` } // ── Page data structs ──────────────────────── // // v0.25.0: Each loader function is a "data provider" keyed by surface ID. // The surface manifest's DataRequires field references these keys. // Currently 1:1 (one loader per surface). Future: composite loaders // that assemble data from multiple providers for dashboard-style surfaces. // AdminPageData is what the admin surface templates receive. type AdminPageData struct { Section string `json:"section"` Category string `json:"category"` ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"` Teams []TeamOption `json:"teams"` Users []UserRow `json:"users,omitempty"` Roles []RoleConfig `json:"roles"` Policies any `json:"policies,omitempty"` ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 } // ChatPageData is what the chat surface templates receive. type ChatPageData struct { ChatID string `json:"chat_id,omitempty"` } // SettingsPageData is what the settings surface templates receive. // Feature gates (BYOK, personas) moved to sw.auth.policies in v0.37.19. type SettingsPageData struct { Section string `json:"section"` ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 } // ── Loader registration ────────────────────── // TeamAdminPageData is what the team-admin surface receives. type TeamAdminPageData struct { Section string `json:"section"` ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 } func (e *Engine) registerLoaders() { e.RegisterLoader("admin", e.adminLoader) e.RegisterLoader("settings", e.settingsLoader) e.RegisterLoader("team-admin", e.teamAdminLoader) } // Used for manifest validation — DataRequires entries must match a key here. func (e *Engine) ListDataProviders() []string { keys := make([]string, 0, len(e.loaders)) for k := range e.loaders { keys = append(keys, k) } return keys } // ── Admin loader ───────────────────────────── // Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope). func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) { ctx := context.Background() section := c.Param("section") if section == "" { section = "users" } data := &AdminPageData{ Section: section, Category: sectionCategory(section), } rolesMap, ok := raw["roles"].(map[string]any) if !ok { rolesMap = raw } for _, name := range roleNames { rc := RoleConfig{Name: name} if roleData, ok := rolesMap[name].(map[string]any); ok { if primary, ok := roleData["primary"].(map[string]any); ok { rc.Primary = &RoleSelection{ ProviderID: fmt.Sprintf("%v", primary["provider_config_id"]), ModelID: fmt.Sprintf("%v", primary["model_id"]), } } if fallback, ok := roleData["fallback"].(map[string]any); ok { rc.Fallback = &RoleSelection{ ProviderID: fmt.Sprintf("%v", fallback["provider_config_id"]), ModelID: fmt.Sprintf("%v", fallback["model_id"]), } } } roles = append(roles, rc) } return roles } // ── Admin helpers ──────────────────────────── // sectionCategory maps an admin section to its category tab. func sectionCategory(section string) string { switch section { case "users", "teams", "groups": return "people" case "providers", "models", "personas", "roles", "knowledgeBases", "memory": return "ai" case "health", "routing", "capabilities": return "routing" case "settings", "storage", "packages", "channels", "broadcast": return "system" case "usage", "audit", "stats": return "monitoring" default: return "ai" // default landing } } // loadProviderTypes returns the registered provider type metadata. func loadProviderTypes() []ProviderTypeOption { out := make([]ProviderTypeOption, 0, len(types)) for _, t := range types { out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name}) } return out } // loadUsers returns the user list for the admin users table. func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow { if s.Users == nil { return nil } users, _, err := s.Users.List(ctx, store.ListOptions{Limit: 500, Sort: "username", Order: "asc"}) if err != nil { log.Printf("[pages/admin] Failed to list users: %v", err) return nil } out := make([]UserRow, 0, len(users)) for _, u := range users { out = append(out, UserRow{ ID: u.ID, Username: u.Username, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, IsActive: u.IsActive, }) } return out } // ── Settings loader ────────────────────────── // v0.22.7: Reads feature gates from GlobalConfig to control // which nav links/tabs are visible (BYOK, User Personas). func (e *Engine) teamAdminLoader(c *gin.Context, s store.Stores) (any, error) { section := c.Param("section") if section == "" { section = "members" } return &TeamAdminPageData{ Section: section, ConfigSections: configSectionsForSurface(context.Background(), s, "team-admin"), }, nil } func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) { section := c.Param("section") if section == "" { section = "general" } return &SettingsPageData{ Section: section, ConfigSections: configSectionsForSurface(context.Background(), s, "settings"), }, nil } // ── Config section discovery (v0.38.3) ─────── // // Packages declare a config_section in their manifest. This helper scans // enabled packages and returns entries whose surfaces array includes the // target surface. No new tables or endpoints — purely manifest-driven. func configSectionsForSurface(ctx context.Context, s store.Stores, surfaceID string) []ConfigSectionEntry { if s.Packages == nil { return nil } pkgs, err := s.Packages.List(ctx) if err != nil { log.Printf("[pages] config sections: failed to list packages: %v", err) return nil } var sections []ConfigSectionEntry for _, pkg := range pkgs { if !pkg.Enabled || pkg.Manifest == nil { continue } csRaw, ok := pkg.Manifest["config_section"] if !ok { continue } cs, ok := csRaw.(map[string]any) if !ok { continue } // Check if this section targets the requested surface surfacesRaw, _ := cs["surfaces"].([]any) if len(surfacesRaw) == 0 { continue } found := false for _, v := range surfacesRaw { if s, ok := v.(string); ok && s == surfaceID { found = true break } } if !found { continue } label, _ := cs["label"].(string) if label == "" { label = pkg.Title } icon, _ := cs["icon"].(string) component, _ := cs["component"].(string) if component == "" { component = "js/config.js" } category, _ := cs["category"].(string) if category == "" { category = "system" } sections = append(sections, ConfigSectionEntry{ PackageID: pkg.ID, Label: label, Icon: icon, Component: component, Category: category, }) } return sections }