diff --git a/docs/my-website/docs/proxy/ui/page_visibility.md b/docs/my-website/docs/proxy/ui/page_visibility.md new file mode 100644 index 00000000000..06b06f33219 --- /dev/null +++ b/docs/my-website/docs/proxy/ui/page_visibility.md @@ -0,0 +1,121 @@ +import Image from '@theme/IdealImage'; + +# Control Page Visibility for Internal Users + +Configure which navigation tabs and pages are visible to internal users (non-admin developers) in the LiteLLM UI. + +Use this feature to simplify the UI and control which pages your internal users/developers can see when signing in. + +## Overview + +By default, all pages accessible to internal users are visible in the navigation sidebar. The page visibility control allows admins to restrict which pages internal users can see, creating a more focused and streamlined experience. + + +## Configure Page Visibility + +### 1. Navigate to Settings + +Click the **Settings** icon in the sidebar. + +![Navigate to Settings](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/cbb6f272-ab18-4996-b57d-7ed4aad721ea/ascreenshot_ab80f3175b1a41b0bdabdd2cd3980573_text_export.jpeg) + +### 2. Go to Admin Settings + +Click **Admin Settings** from the settings menu. + +![Go to Admin Settings](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/e2b327bf-1cfd-4519-a9ce-8a6ecb2de53a/ascreenshot_23bb1577b3f84d22be78e0faa58dee3d_text_export.jpeg) + +### 3. Select UI Settings + +Click **UI Settings** to access the page visibility controls. + +![Select UI Settings](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/fff0366a-4944-457a-8f6a-e22018dde108/ascreenshot_0e268e8651654e75bb9fb40d2ed366a9_text_export.jpeg) + +### 4. Open Page Visibility Configuration + +Click **Configure Page Visibility** to expand the configuration panel. + +![Open Configuration](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/3a4761d6-145a-4afd-8abf-d92744b9ac9f/ascreenshot_23c16eb79c32481887b879d961f1f00a_text_export.jpeg) + +### 5. Select Pages to Make Visible + +Check the boxes for the pages you want internal users to see. Pages are organized by category for easy navigation. + +![Select Pages](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/b9c96b54-6c20-484f-8b0b-3a86decb5717/ascreenshot_3347ade01ebe4ea390bc7b57e53db43f_text_export.jpeg) + +**Available pages include:** +- Virtual Keys +- Playground +- Models + Endpoints +- Agents +- MCP Servers +- Search Tools +- Vector Stores +- Logs +- Teams +- Organizations +- Usage +- Budgets +- And more... + +### 6. Save Your Configuration + +Click **Save Page Visibility Settings** to apply the changes. + +![Save Settings](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/8a215378-44f5-4bb8-b984-06fa2aa03903/ascreenshot_44e7aeebe25a477ba92f73a3ed3df644_text_export.jpeg) + +### 7. Verify Changes + +Internal users will now only see the selected pages in their navigation sidebar. + +![Verify Changes](https://colony-recorder.s3.amazonaws.com/files/2026-01-28/493a7718-b276-40b9-970f-5814054932d9/ascreenshot_ad23b8691f824095ba60256f91ad24f8_text_export.jpeg) + +## Reset to Default + +To restore all pages to internal users: + +1. Open the Page Visibility configuration +2. Click **Reset to Default (All Pages)** +3. Click **Save Page Visibility Settings** + +This will clear the restriction and show all accessible pages to internal users. + +## API Configuration + +You can also configure page visibility programmatically using the API: + +### Get Current Settings + +```bash +curl -X GET 'http://localhost:4000/ui_settings/get' \ + -H 'Authorization: Bearer ' +``` + +### Update Page Visibility + +```bash +curl -X PATCH 'http://localhost:4000/ui_settings/update' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "enabled_ui_pages_internal_users": [ + "api-keys", + "agents", + "mcp-servers", + "logs", + "teams" + ] + }' +``` + +### Clear Page Visibility Restrictions + +```bash +curl -X PATCH 'http://localhost:4000/ui_settings/update' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "enabled_ui_pages_internal_users": null + }' +``` + diff --git a/docs/my-website/docs/routing.md b/docs/my-website/docs/routing.md index b5ece7237aa..2b3a28edf75 100644 --- a/docs/my-website/docs/routing.md +++ b/docs/my-website/docs/routing.md @@ -828,6 +828,7 @@ asyncio.run(router_acompletion()) ``` + ## Traffic Mirroring / Silent Experiments diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index ef7573e9cef..4cc46f97727 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -274,11 +274,19 @@ const sidebars = { "proxy/custom_sso", "proxy/ai_hub", "proxy/model_compare_ui", - "proxy/public_teams", - "proxy/self_serve", - "proxy/ui/bulk_edit_users", "proxy/ui_credentials", "tutorials/scim_litellm", + { + type: "category", + label: "UI User/Team Management", + items: [ + "proxy/access_control", + "proxy/public_teams", + "proxy/self_serve", + "proxy/ui/bulk_edit_users", + "proxy/ui/page_visibility", + ] + }, { type: "category", label: "UI Usage Tracking", diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 09c14bc42c1..4a0268eeede 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -78,6 +78,11 @@ class UISettings(BaseModel): description="Prevents Team Admins from deleting users from the teams they manage. Useful for SCIM provisioning where team membership is defined externally.", ) + enabled_ui_pages_internal_users: Optional[List[str]] = Field( + default=None, + description="List of page keys that internal users (non-admins) can see in the UI sidebar. If not set, all pages are visible based on role permissions.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -89,6 +94,7 @@ class UISettingsResponse(SettingsResponse): ALLOWED_UI_SETTINGS_FIELDS = { "disable_model_add_for_internal_users", "disable_team_admin_delete_team_user", + "enabled_ui_pages_internal_users", } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index 8b934e10779..26f5786b413 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -1,4 +1,9 @@ +"use client"; + import Sidebar from "@/components/leftnav"; +import { getUISettings } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useEffect, useState } from "react"; interface SidebarProviderProps { setPage: (page: string) => void; @@ -7,7 +12,44 @@ interface SidebarProviderProps { } const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => { - return ; + const { accessToken } = useAuthorized(); + const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); + + useEffect(() => { + const fetchUISettings = async () => { + if (!accessToken) { + console.log("[SidebarProvider] No access token, skipping UI settings fetch"); + return; + } + + try { + console.log("[SidebarProvider] Fetching UI settings from /get/ui_settings"); + const settings = await getUISettings(accessToken); + console.log("[SidebarProvider] UI settings response:", settings); + + // API returns 'values' not 'settings' + if (settings?.values?.enabled_ui_pages_internal_users !== undefined) { + console.log("[SidebarProvider] Setting enabled pages:", settings.values.enabled_ui_pages_internal_users); + setEnabledPagesInternalUsers(settings.values.enabled_ui_pages_internal_users); + } else { + console.log("[SidebarProvider] No enabled_ui_pages_internal_users in response (all pages visible by default)"); + } + } catch (error) { + console.error("[SidebarProvider] Failed to fetch UI settings:", error); + } + }; + + fetchUISettings(); + }, [accessToken]); + + return ( + + ); }; export default SidebarProvider; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx new file mode 100644 index 00000000000..47e4a8bb7f3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { getAvailablePages } from "@/components/page_utils"; +import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd"; +import { useMemo, useState } from "react"; + +interface PageVisibilitySettingsProps { + enabledPagesInternalUsers: string[] | null | undefined; + enabledPagesPropertyDescription?: string; + isUpdating: boolean; + onUpdate: (settings: { enabled_ui_pages_internal_users: string[] | null }) => void; +} + +export default function PageVisibilitySettings({ + enabledPagesInternalUsers, + enabledPagesPropertyDescription, + isUpdating, + onUpdate, +}: PageVisibilitySettingsProps) { + // Check if page visibility is set (null/undefined means "not set" = all pages visible) + const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined; + + // Get available pages from leftnav configuration + const availablePages = useMemo(() => getAvailablePages(), []); + + // Group pages by their group for better UI + const pagesByGroup = useMemo(() => { + const grouped: Record = {}; + availablePages.forEach((page) => { + if (!grouped[page.group]) { + grouped[page.group] = []; + } + grouped[page.group].push(page); + }); + return grouped; + }, [availablePages]); + + // Local state for page selection + const [selectedPages, setSelectedPages] = useState(enabledPagesInternalUsers || []); + + // Update local state when data changes + useMemo(() => { + if (enabledPagesInternalUsers) { + setSelectedPages(enabledPagesInternalUsers); + } else { + setSelectedPages([]); + } + }, [enabledPagesInternalUsers]); + + const handleSavePageVisibility = () => { + onUpdate({ enabled_ui_pages_internal_users: selectedPages.length > 0 ? selectedPages : null }); + }; + + const handleResetToDefault = () => { + setSelectedPages([]); + onUpdate({ enabled_ui_pages_internal_users: null }); + }; + + return ( + + + + Internal User Page Visibility + {!isPageVisibilitySet && ( + + Not set (all pages visible) + + )} + {isPageVisibilitySet && ( + + {selectedPages.length} page{selectedPages.length !== 1 ? "s" : ""} selected + + )} + + {enabledPagesPropertyDescription && ( + {enabledPagesPropertyDescription} + )} + + By default, all pages are visible to internal users. Select specific pages to restrict visibility. + + + Note: Only pages accessible to internal user roles are shown here. Admin-only pages are excluded as they + cannot be made visible to internal users regardless of this setting. + + + + + + + {Object.entries(pagesByGroup).map(([groupName, pages]) => ( +
+ + {groupName} + + + {pages.map((page) => ( +
+ + + {page.label} + + {page.description} + + + +
+ ))} +
+
+ ))} +
+
+ + + + {isPageVisibilitySet && ( + + )} + +
+ ), + }, + ]} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 1cc493194e9..6cc9cbf4309 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -4,7 +4,8 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import NotificationManager from "@/components/molecules/notifications_manager"; -import { Alert, Card, Skeleton, Space, Switch, Typography } from "antd"; +import PageVisibilitySettings from "./PageVisibilitySettings"; +import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd"; export default function UISettings() { const { accessToken } = useAuthorized(); @@ -14,6 +15,7 @@ export default function UISettings() { const schema = data?.field_schema; const property = schema?.properties?.disable_model_add_for_internal_users; const disableTeamAdminDeleteProperty = schema?.properties?.disable_team_admin_delete_team_user; + const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -46,6 +48,17 @@ export default function UISettings() { ); }; + const handleUpdatePageVisibility = (settings: { enabled_ui_pages_internal_users: string[] | null }) => { + updateSettings(settings, { + onSuccess: () => { + NotificationManager.success("Page visibility settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }); + }; + return ( {isLoading ? ( @@ -99,6 +112,16 @@ export default function UISettings() { )} + + + + {/* Page Visibility for Internal Users */} + )} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index f24f4d60219..61071211778 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -39,6 +39,7 @@ interface SidebarProps { setPage: (page: string) => void; defaultSelectedKey: string; collapsed?: boolean; + enabledPagesInternalUsers?: string[] | null; } // Menu item configuration @@ -59,28 +60,8 @@ interface MenuGroup { roles?: string[]; } -const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapsed = false }) => { - const { userId, accessToken, userRole } = useAuthorized(); - const { data: organizations } = useOrganizations(); - - // Check if user is an org_admin - const isOrgAdmin = useMemo(() => { - if (!userId || !organizations) return false; - return organizations.some((org: Organization) => - org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"), - ); - }, [userId, organizations]); - - // Navigate to page helper - const navigateToPage = (page: string) => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(page); - }; - - // Menu groups organized by category - const menuGroups: MenuGroup[] = [ +// Menu groups organized by category - defined outside component for export +const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", items: [ @@ -152,7 +133,6 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse page: "vector-stores", label: "Vector Stores", icon: , - roles: all_admin_roles, }, ], }, @@ -337,20 +317,82 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse }, ]; - // Filter items based on user role +const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapsed = false, enabledPagesInternalUsers }) => { + const { userId, accessToken, userRole } = useAuthorized(); + const { data: organizations } = useOrganizations(); + + // Check if user is an org_admin + const isOrgAdmin = useMemo(() => { + if (!userId || !organizations) return false; + return organizations.some((org: Organization) => + org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"), + ); + }, [userId, organizations]); + + // Navigate to page helper + const navigateToPage = (page: string) => { + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", page); + window.history.pushState(null, "", `?${newSearchParams.toString()}`); + setPage(page); + }; + + // Filter items based on user role and enabled pages for internal users const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { + const isAdmin = isAdminRole(userRole); + + // Debug logging + if (enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { + console.log("[LeftNav] Filtering with enabled pages:", { + userRole, + isAdmin, + enabledPagesInternalUsers, + }); + } + return items + .map((item) => ({ + ...item, + children: item.children ? filterItemsByRole(item.children) : undefined, + })) .filter((item) => { // Special handling for organizations menu item - allow org_admins if (item.key === "organizations") { - return !item.roles || item.roles.includes(userRole) || isOrgAdmin; + const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; + if (!hasRoleAccess) return false; + + // Check enabled pages for internal users (non-admins) + if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { + const isIncluded = enabledPagesInternalUsers.includes(item.page); + console.log(`[LeftNav] Page "${item.page}" (${item.key}): ${isIncluded ? "VISIBLE" : "HIDDEN"}`); + return isIncluded; + } + return true; } - return !item.roles || item.roles.includes(userRole); - }) - .map((item) => ({ - ...item, - children: item.children ? filterItemsByRole(item.children) : undefined, - })); + + // Existing role check + if (item.roles && !item.roles.includes(userRole)) return false; + + // Check enabled pages for internal users (non-admins) + if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { + // If item has children, check if any children are visible + if (item.children && item.children.length > 0) { + const hasVisibleChildren = item.children.some((child) => + enabledPagesInternalUsers.includes(child.page) + ); + if (hasVisibleChildren) { + console.log(`[LeftNav] Parent "${item.page}" (${item.key}): VISIBLE (has visible children)`); + return true; + } + } + + const isIncluded = enabledPagesInternalUsers.includes(item.page); + console.log(`[LeftNav] Page "${item.page}" (${item.key}): ${isIncluded ? "VISIBLE" : "HIDDEN"}`); + return isIncluded; + } + + return true; + }); }; // Build menu items with groups @@ -485,3 +527,6 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse }; export default Sidebar; + +// Also export menuGroups for advanced use cases +export { menuGroups }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a1625b6ffbe..758358b8b0d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5330,6 +5330,66 @@ export const getProxyUISettings = async (accessToken: string) => { } }; +export const getUISettings = async (accessToken: string) => { + /** + * Get UI-specific configuration flags from the database + */ + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + console.error("Failed to get UI settings:", errorMessage); + return null; + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to get UI settings:", error); + return null; + } +}; + +export const updateUISettings = async (accessToken: string, settings: any) => { + /** + * Update UI-specific configuration flags in the database + * Only proxy admins can update these settings + */ + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/update/ui_settings` : `/update/ui_settings`; + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(settings), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to update UI settings:", error); + throw error; + } +}; + export const getGuardrailsList = async (accessToken: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/v2/guardrails/list` : `/v2/guardrails/list`; diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts new file mode 100644 index 00000000000..0c3b978a0bb --- /dev/null +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -0,0 +1,44 @@ +/** + * Page metadata for UI Settings configuration + * This file contains descriptions and metadata for all navigation pages + */ + +// Page descriptions for UI Settings configuration +export const pageDescriptions: Record = { + "api-keys": "Manage virtual keys for API access and authentication", + "llm-playground": "Interactive playground for testing LLM requests", + models: "Configure and manage LLM models and endpoints", + agents: "Create and manage AI agents", + "mcp-servers": "Configure Model Context Protocol servers", + guardrails: "Set up content moderation and safety guardrails", + policies: "Define access control and usage policies", + "search-tools": "Configure RAG search and retrieval tools", + "vector-stores": "Manage vector databases for embeddings", + new_usage: "View usage analytics and metrics", + logs: "Access request and response logs", + users: "Manage internal user accounts and permissions", + teams: "Create and manage teams for access control", + organizations: "Manage organizations and their members", + budgets: "Set and monitor spending budgets", + api_ref: "Browse API documentation and endpoints", + "model-hub-table": "Explore available AI models and providers", + "learning-resources": "Access tutorials and documentation", + caching: "Configure response caching settings", + "transform-request": "Set up request transformation rules", + "cost-tracking": "Track and analyze API costs", + "ui-theme": "Customize dashboard appearance", + "tag-management": "Organize resources with tags", + prompts: "Manage and version prompt templates", + "claude-code-plugins": "Configure Claude Code plugins", + usage: "View legacy usage dashboard", + "router-settings": "Configure routing and load balancing settings", + "logging-and-alerts": "Set up logging and alert configurations", + "admin-panel": "Access admin panel and settings", +}; + +export interface PageMetadata { + page: string; + label: string; + group: string; + description: string; +} diff --git a/ui/litellm-dashboard/src/components/page_utils.test.ts b/ui/litellm-dashboard/src/components/page_utils.test.ts new file mode 100644 index 00000000000..fed6c381498 --- /dev/null +++ b/ui/litellm-dashboard/src/components/page_utils.test.ts @@ -0,0 +1,219 @@ +/** + * Tests to ensure page metadata stays in sync with leftnav configuration + * This catches issues when leftnav structure changes but page_utils/page_metadata aren't updated + */ + +import { describe, it, expect } from "vitest"; +import { getAvailablePages } from "./page_utils"; +import { menuGroups } from "./leftnav"; +import { pageDescriptions } from "./page_metadata"; + +describe("Page Utils - LeftNav Sync", () => { + it("should return all pages from leftnav configuration", () => { + const availablePages = getAvailablePages(); + + // Should have pages + expect(availablePages.length).toBeGreaterThan(0); + + // Each page should have required fields + availablePages.forEach((page) => { + expect(page).toHaveProperty("page"); + expect(page).toHaveProperty("label"); + expect(page).toHaveProperty("group"); + expect(page).toHaveProperty("description"); + expect(typeof page.page).toBe("string"); + expect(typeof page.label).toBe("string"); + expect(typeof page.group).toBe("string"); + expect(typeof page.description).toBe("string"); + }); + }); + + it("should include all navigable pages from menuGroups", () => { + const availablePages = getAvailablePages(); + const availablePageKeys = availablePages.map((p) => p.page); + + // Collect all page keys from menuGroups (excluding parent containers) + const menuPageKeys: string[] = []; + const excludedParents = ["tools", "experimental", "settings"]; + + menuGroups.forEach((group) => { + group.items.forEach((item) => { + if (item.page && !excludedParents.includes(item.page)) { + menuPageKeys.push(item.page); + } + + // Add children + if (item.children) { + item.children.forEach((child) => { + menuPageKeys.push(child.page); + }); + } + }); + }); + + // Every menu page should be in available pages + menuPageKeys.forEach((pageKey) => { + expect( + availablePageKeys, + `Page "${pageKey}" from menuGroups should be in getAvailablePages() output` + ).toContain(pageKey); + }); + }); + + it("should not include parent container pages (tools, experimental, settings)", () => { + const availablePages = getAvailablePages(); + const availablePageKeys = availablePages.map((p) => p.page); + + const excludedParents = ["tools", "experimental", "settings"]; + + excludedParents.forEach((parent) => { + expect( + availablePageKeys, + `Parent container "${parent}" should not be in available pages` + ).not.toContain(parent); + }); + }); + + it("should have descriptions for all pages", () => { + const availablePages = getAvailablePages(); + + availablePages.forEach((page) => { + expect( + page.description, + `Page "${page.page}" should have a description` + ).toBeTruthy(); + + expect( + page.description, + `Page "${page.page}" should not have placeholder description` + ).not.toBe("No description available"); + }); + }); + + it("should have pageDescriptions entry for all navigable pages in menuGroups", () => { + // Collect all page keys from menuGroups + const menuPageKeys: string[] = []; + const excludedParents = ["tools", "experimental", "settings"]; + + menuGroups.forEach((group) => { + group.items.forEach((item) => { + if (item.page && !excludedParents.includes(item.page)) { + menuPageKeys.push(item.page); + } + + if (item.children) { + item.children.forEach((child) => { + menuPageKeys.push(child.page); + }); + } + }); + }); + + // Every menu page should have a description + const missingDescriptions: string[] = []; + menuPageKeys.forEach((pageKey) => { + if (!pageDescriptions[pageKey]) { + missingDescriptions.push(pageKey); + } + }); + + expect( + missingDescriptions, + `These pages are missing descriptions in page_metadata.ts: ${missingDescriptions.join(", ")}` + ).toHaveLength(0); + }); + + it("should not have orphaned descriptions (descriptions for pages not in menuGroups)", () => { + // Collect all page keys from menuGroups + const menuPageKeys: string[] = []; + const excludedParents = ["tools", "experimental", "settings"]; + + menuGroups.forEach((group) => { + group.items.forEach((item) => { + if (item.page && !excludedParents.includes(item.page)) { + menuPageKeys.push(item.page); + } + + if (item.children) { + item.children.forEach((child) => { + menuPageKeys.push(child.page); + }); + } + }); + }); + + // Check for descriptions that don't match any menu page + const orphanedDescriptions: string[] = []; + Object.keys(pageDescriptions).forEach((descKey) => { + if (!menuPageKeys.includes(descKey)) { + orphanedDescriptions.push(descKey); + } + }); + + expect( + orphanedDescriptions, + `These descriptions don't match any page in menuGroups: ${orphanedDescriptions.join(", ")}. Remove them or add the pages to leftnav.` + ).toHaveLength(0); + }); + + it("should have proper group hierarchy for nested pages", () => { + const availablePages = getAvailablePages(); + + // Find pages that should be nested (children of Tools, Experimental, Settings) + const nestedPages = availablePages.filter((page) => + page.group.includes(" > ") + ); + + // Each nested page should have parent > child format + nestedPages.forEach((page) => { + const parts = page.group.split(" > "); + expect( + parts.length, + `Nested page "${page.page}" should have exactly 2 parts in group hierarchy` + ).toBe(2); + + // Parent should be one of the group labels + const parentGroup = parts[0]; + const groupLabels = menuGroups.map((g) => g.groupLabel); + expect( + groupLabels, + `Parent group "${parentGroup}" for page "${page.page}" should be a valid group label` + ).toContain(parentGroup); + }); + }); + + it("should have unique page keys", () => { + const availablePages = getAvailablePages(); + const pageKeys = availablePages.map((p) => p.page); + const uniquePageKeys = new Set(pageKeys); + + expect( + pageKeys.length, + "All page keys should be unique (no duplicates)" + ).toBe(uniquePageKeys.size); + }); + + it("should match the structure expected by PageVisibilitySettings component", () => { + const availablePages = getAvailablePages(); + + // Group pages by their group (same logic as in PageVisibilitySettings) + const grouped: Record = {}; + availablePages.forEach((page) => { + if (!grouped[page.group]) { + grouped[page.group] = []; + } + grouped[page.group].push(page); + }); + + // Should have multiple groups + expect(Object.keys(grouped).length).toBeGreaterThan(1); + + // Each group should have at least one page + Object.entries(grouped).forEach(([groupName, pages]) => { + expect( + pages.length, + `Group "${groupName}" should have at least one page` + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/page_utils.ts b/ui/litellm-dashboard/src/components/page_utils.ts new file mode 100644 index 00000000000..7461f2b4a88 --- /dev/null +++ b/ui/litellm-dashboard/src/components/page_utils.ts @@ -0,0 +1,75 @@ +/** + * Utility functions for working with navigation pages + */ + +import { menuGroups } from "./leftnav"; +import { pageDescriptions, PageMetadata } from "./page_metadata"; +import { internalUserRoles } from "@/utils/roles"; + +/** + * Check if a page is accessible to internal users + * A page is accessible if: + * 1. It has no role restrictions, OR + * 2. Its roles include at least one internal user role + */ +const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => { + if (!pageRoles || pageRoles.length === 0) { + return true; // No role restrictions + } + + // Check if any of the page's roles match internal user roles + return pageRoles.some(role => internalUserRoles.includes(role)); +}; + +/** + * Get all available pages from the navigation menu configuration + * Used by UI Settings to display available pages for visibility control + * + * IMPORTANT: Only returns pages that internal users can access. + * Pages restricted to admin-only roles are excluded because internal users + * cannot see them regardless of the UI visibility setting. + */ +export const getAvailablePages = (): PageMetadata[] => { + const pages: PageMetadata[] = []; + + menuGroups.forEach((group) => { + group.items.forEach((item) => { + // Add top-level items (skip parent containers like 'tools', 'experimental', 'settings') + // Also skip items that internal users cannot access + if ( + item.page && + item.page !== "tools" && + item.page !== "experimental" && + item.page !== "settings" && + isPageAccessibleToInternalUsers(item.roles) + ) { + const label = typeof item.label === "string" ? item.label : item.key; + pages.push({ + page: item.page, + label: label, + group: group.groupLabel, + description: pageDescriptions[item.page] || "No description available", + }); + } + + // Add children items (also skip those internal users cannot access) + if (item.children) { + const parentLabel = typeof item.label === "string" ? item.label : item.key; + item.children.forEach((child) => { + // Include if internal users can access + if (isPageAccessibleToInternalUsers(child.roles)) { + const childLabel = typeof child.label === "string" ? child.label : child.key; + pages.push({ + page: child.page, + label: childLabel, + group: `${group.groupLabel} > ${parentLabel}`, + description: pageDescriptions[child.page] || "No description available", + }); + } + }); + } + }); + }); + + return pages; +};