[Feat] UI: Allow Admins to control what pages are visible on LeftNav (#19907)

* feat: enabled_ui_pages_internal_users

* init ui for internal user controsl

* fix ui settings

* fix build

* fix leftnav

* fix leftnav

* test fixes

* fix leftnav

* isPageAccessibleToInternalUsers

* docs fix

* docs ui viz
This commit is contained in:
Ishaan Jaff 2026-01-27 19:31:24 -08:00 • committed by GitHub
parent 9a245031bd
commit 3080e04180
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 827 additions and 37 deletions

View file

@ -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 <your-admin-key>'
```
### Update Page Visibility
```bash
curl -X PATCH 'http://localhost:4000/ui_settings/update' \
-H 'Authorization: Bearer <your-admin-key>' \
-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 <your-admin-key>' \
-H 'Content-Type: application/json' \
-d '{
"enabled_ui_pages_internal_users": null
}'
```

View file

@ -828,6 +828,7 @@ asyncio.run(router_acompletion())
```
</TabItem>
</Tabs>
## Traffic Mirroring / Silent Experiments

View file

@ -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",

View file

@ -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",
}

View file

@ -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 <Sidebar setPage={setPage} defaultSelectedKey={defaultSelectedKey} collapsed={sidebarCollapsed} />;
const { accessToken } = useAuthorized();
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(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 (
<Sidebar
setPage={setPage}
defaultSelectedKey={defaultSelectedKey}
collapsed={sidebarCollapsed}
enabledPagesInternalUsers={enabledPagesInternalUsers}
/>
);
};
export default SidebarProvider;

View file

@ -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<string, typeof availablePages> = {};
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<string[]>(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 (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space direction="vertical" size={4}>
<Space align="center">
<Typography.Text strong>Internal User Page Visibility</Typography.Text>
{!isPageVisibilitySet && (
<Tag color="default" style={{ marginLeft: "8px" }}>
Not set (all pages visible)
</Tag>
)}
{isPageVisibilitySet && (
<Tag color="blue" style={{ marginLeft: "8px" }}>
{selectedPages.length} page{selectedPages.length !== 1 ? "s" : ""} selected
</Tag>
)}
</Space>
{enabledPagesPropertyDescription && (
<Typography.Text type="secondary">{enabledPagesPropertyDescription}</Typography.Text>
)}
<Typography.Text type="secondary" style={{ fontSize: "12px", fontStyle: "italic" }}>
By default, all pages are visible to internal users. Select specific pages to restrict visibility.
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px", color: "#8b5cf6" }}>
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.
</Typography.Text>
</Space>
<Collapse
items={[
{
key: "page-visibility",
label: "Configure Page Visibility",
children: (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Checkbox.Group value={selectedPages} onChange={setSelectedPages} style={{ width: "100%" }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{Object.entries(pagesByGroup).map(([groupName, pages]) => (
<div key={groupName}>
<Typography.Text
strong
style={{
fontSize: "11px",
color: "#6b7280",
letterSpacing: "0.05em",
display: "block",
marginBottom: "8px",
}}
>
{groupName}
</Typography.Text>
<Space direction="vertical" size="small" style={{ marginLeft: "16px", width: "100%" }}>
{pages.map((page) => (
<div key={page.page} style={{ marginBottom: "4px" }}>
<Checkbox value={page.page}>
<Space direction="vertical" size={0}>
<Typography.Text>{page.label}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
{page.description}
</Typography.Text>
</Space>
</Checkbox>
</div>
))}
</Space>
</div>
))}
</Space>
</Checkbox.Group>
<Space>
<Button type="primary" onClick={handleSavePageVisibility} loading={isUpdating} disabled={isUpdating}>
Save Page Visibility Settings
</Button>
{isPageVisibilitySet && (
<Button onClick={handleResetToDefault} loading={isUpdating} disabled={isUpdating}>
Reset to Default (All Pages)
</Button>
)}
</Space>
</Space>
),
},
]}
/>
</Space>
);
}

View file

@ -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 (
<Card title="UI Settings">
{isLoading ? (
@ -99,6 +112,16 @@ export default function UISettings() {
)}
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}
enabledPagesPropertyDescription={enabledPagesProperty?.description}
isUpdating={isUpdating}
onUpdate={handleUpdatePageVisibility}
/>
</Space>
)}
</Card>

View file

@ -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<SidebarProps> = ({ 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<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
page: "vector-stores",
label: "Vector Stores",
icon: <DatabaseOutlined />,
roles: all_admin_roles,
},
],
},
@ -337,20 +317,82 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
},
];
// Filter items based on user role
const Sidebar: React.FC<SidebarProps> = ({ 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
.filter((item) => {
// Special handling for organizations menu item - allow org_admins
if (item.key === "organizations") {
return !item.roles || item.roles.includes(userRole) || isOrgAdmin;
}
return !item.roles || item.roles.includes(userRole);
})
.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") {
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;
}
// 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<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
};
export default Sidebar;
// Also export menuGroups for advanced use cases
export { menuGroups };

View file

@ -5336,6 +5336,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`;

View file

@ -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<string, string> = {
"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;
}

View file

@ -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<string, typeof availablePages> = {};
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);
});
});
});

View file

@ -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;
};