From 3fec4705d7c721b36f893927a507c984248a8425 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:33:31 -0700 Subject: [PATCH] fix(projects): show Projects to team and org admins and scope it correctly (#41325) * fix(ui): show Projects nav to team and org admins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): satisfy frontend lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format leftnav regression tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(projects): let org admins see projects of every team in their orgs /project/list and /project/info only knew proxy admins and team members, so an org admin saw projects only for teams they had personally joined. Both now use the same team access check as /team/info. * fix(ui): one shared Projects access rule for nav, data and actions The Projects query skipped global org_admin users, the page-visibility picker could never offer Projects, and New/Edit showed to users the backend would reject. Nav and queries now share one rule, Projects is selectable in the allowlist, New/Edit follow team_admin_editable_team_fields, and the project modal only lists teams the user administers. * fix(projects): record IN-list bounds for project visibility filters Both filters are bounded by one caller's team memberships and admin orgs. Also cover a project whose team was deleted. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri --- .../management_endpoints/project_endpoints.py | 61 +++-- litellm/proxy/management/teams/authz.py | 6 + litellm/proxy/management/users/service.py | 13 +- .../unbounded_in_baseline.txt | 1 - .../test_project_endpoints_prisma.py | 215 ++++++++++++++++++ .../hooks/projects/projectAccess.ts | 46 ++++ .../hooks/projects/useProjectDetails.test.ts | 14 +- .../hooks/projects/useProjectDetails.ts | 4 +- .../hooks/projects/useProjects.test.ts | 4 +- .../(dashboard)/hooks/projects/useProjects.ts | 6 +- .../_components/ProjectDetailsPage.test.tsx | 46 ++++ .../_components/ProjectDetailsPage.tsx | 17 +- .../ProjectModals/ProjectBaseForm.test.tsx | 32 +++ .../ProjectModals/ProjectBaseForm.tsx | 3 +- .../_components/ProjectsPage.test.tsx | 47 ++++ .../projects/_components/ProjectsPage.tsx | 19 +- .../src/components/leftnav.test.tsx | 114 +++++++++- .../src/components/leftnav.tsx | 11 +- .../src/components/page_utils.test.ts | 7 + .../src/components/page_utils.ts | 10 +- 20 files changed, 616 insertions(+), 60 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index eb1cd27abe9..7277282968e 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -22,7 +22,9 @@ from litellm._uuid import uuid from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import delete_cached_project_object from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.management.teams.authz import is_team_admin +from litellm.proxy.management.teams.authz import TEAM_OR_ORG_ADMIN, TeamAccess, is_team_admin, is_team_member +from litellm.proxy.management.teams.dependencies import get_team_access +from litellm.proxy.management.users.service import org_admin_org_ids from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects from litellm.proxy.management_helpers.utils import ( @@ -118,6 +120,37 @@ async def _check_user_permission_for_project( return is_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or []) +async def _can_view_team_projects( + user_api_key_dict: UserAPIKeyAuth, + team_id: str | None, + prisma_client: PrismaClient, + team_access: TeamAccess, +) -> bool: + if user_api_key_has_admin_view(user_api_key_dict): + return True + if not team_id or not user_api_key_dict.user_id: + return False + team_row: Final = await _team_table(prisma_client).find_unique(where={"team_id": team_id}) + if team_row is None: + return False + team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) + return is_team_member(user_api_key_dict, team) or await team_access.allows( + user_api_key_dict, team, TEAM_OR_ORG_ADMIN + ) + + +def _visible_projects_where(team_ids: list[str], admin_org_ids: frozenset[str]) -> dict[str, object]: + # bounded-ok: one caller's team memberships + member_scope: Final[dict[str, object]] = {"team_id": {"in": team_ids}} + if not admin_org_ids: + return member_scope + org_scope: Final[dict[str, object]] = { + # bounded-ok: orgs one caller administers + "litellm_team_table": {"is": {"organization_id": {"in": sorted(admin_org_ids)}}} + } + return {"OR": [member_scope, org_scope]} + + async def _validate_team_exists( team_id: str, prisma_client: PrismaClient, @@ -973,6 +1006,7 @@ async def delete_project( async def project_info( project_id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + team_access: TeamAccess = Depends(get_team_access), ): """ Get information about a specific project @@ -1009,21 +1043,7 @@ async def project_info( param="project_id", ) - # Check if user has access to this project (admin or team member) - is_admin = user_api_key_has_admin_view(user_api_key_dict) - is_team_member = False - - if project.team_id and user_api_key_dict.user_id: - team = await _team_table(prisma_client).find_unique(where={"team_id": project.team_id}) - if team: - caller_user_id = user_api_key_dict.user_id - for m in team.members_with_roles or []: - m_user_id = m.get("user_id") if isinstance(m, dict) else getattr(m, "user_id", None) - if m_user_id == caller_user_id: - is_team_member = True - break - - if not (is_admin or is_team_member): + if not await _can_view_team_projects(user_api_key_dict, project.team_id, prisma_client, team_access): raise HTTPException( status_code=403, detail={"error": "You don't have access to this project"}, @@ -1072,16 +1092,13 @@ async def list_projects( include={"litellm_budget_table": True, "object_permission": True} ) else: - # Look up the user's team memberships via the reverse-index on - # LiteLLM_UserTable.teams (maintained by team_member_add alongside - # members_with_roles). This avoids a full scan of all team rows. user_record: Final = await _user_table(prisma_client).find_unique( where={"user_id": user_api_key_dict.user_id}, + include={"organization_memberships": True}, ) - user_team_ids: list[str] = user_record.teams if user_record is not None and user_record.teams else [] - + user: Final = None if user_record is None else LiteLLM_UserTable.model_validate(user_record.model_dump()) projects = await _project_table(prisma_client).find_many( - where={"team_id": {"in": user_team_ids}}, + where=_visible_projects_where(user.teams if user is not None else [], org_admin_org_ids(user)), include={"litellm_budget_table": True, "object_permission": True}, ) diff --git a/litellm/proxy/management/teams/authz.py b/litellm/proxy/management/teams/authz.py index 77af588c636..869ee01a302 100644 --- a/litellm/proxy/management/teams/authz.py +++ b/litellm/proxy/management/teams/authz.py @@ -44,6 +44,12 @@ class TeamAccess: return await self.org_roles.is_org_admin(caller.user_id, team.organization_id) +def is_team_member(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool: + return user_api_key_dict.user_id is not None and any( + member.user_id == user_api_key_dict.user_id for member in team_obj.members_with_roles + ) + + def is_team_admin(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool: return any( member.user_id is not None and member.user_id == user_api_key_dict.user_id and member.role == "admin" diff --git a/litellm/proxy/management/users/service.py b/litellm/proxy/management/users/service.py index 5bf19c0c885..8a9c30ddbeb 100644 --- a/litellm/proxy/management/users/service.py +++ b/litellm/proxy/management/users/service.py @@ -10,13 +10,20 @@ if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient, ProxyLogging -def holds_org_admin(user: LiteLLM_UserTable | None, organization_id: str) -> bool: - return user is not None and any( - membership.organization_id == organization_id and membership.user_role == LitellmUserRoles.ORG_ADMIN.value +def org_admin_org_ids(user: LiteLLM_UserTable | None) -> frozenset[str]: + if user is None: + return frozenset() + return frozenset( + membership.organization_id for membership in user.organization_memberships or [] + if membership.user_role == LitellmUserRoles.ORG_ADMIN.value ) +def holds_org_admin(user: LiteLLM_UserTable | None, organization_id: str) -> bool: + return organization_id in org_admin_org_ids(user) + + @dataclass(frozen=True, slots=True) class PrismaOrgRoles: prisma_client: PrismaClient | None diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt index 01d8760855f..129cf1b7797 100644 --- a/tests/code_coverage_tests/unbounded_in_baseline.txt +++ b/tests/code_coverage_tests/unbounded_in_baseline.txt @@ -1,7 +1,6 @@ # Grandfathered findings of check_unbounded_in_lists.py: path::scope::kind::subject::occurrence. # Fix a site and delete its line; regenerate with `check_unbounded_in_lists.py --update-baseline`. enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py CheckResponsesCost.check_responses_cost prisma id.in `[job.id for job in completed_jobs]` 0 -enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py list_projects prisma team_id.in `user_team_ids` 0 litellm/integrations/shadow_eval_logger.py ShadowEvalLogger._active_jobs prisma job_id.in `[str(record.id) for record in records]` 0 litellm/llms/litellm_proxy/skills/handler.py LiteLLMSkillsHandler.list_skills prisma created_by.in `owner_scopes` 0 litellm/proxy/_experimental/mcp_server/db.py get_mcp_servers prisma server_id.in `server_ids` 0 diff --git a/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index 052300ed2d2..df97ea81322 100644 --- a/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/unit/enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1,5 +1,9 @@ import os import traceback +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Final from litellm._uuid import uuid from unittest import mock @@ -37,8 +41,14 @@ from litellm.proxy._types import ( DeleteProjectRequest, NewTeamRequest, UserAPIKeyAuth, + LiteLLM_OrganizationMembershipTable, + LiteLLM_TeamTable, + LiteLLM_UserTable, + Member, ProxyException, ) +from litellm.proxy.management.teams.authz import TeamAccess +from litellm.proxy.management.users.service import PrismaOrgRoles from tests._master_key import MASTER_KEY proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) @@ -1381,3 +1391,208 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch): assert "prod-models" in str(exc_info.value) assert "expand to multiple models at request time" in str(exc_info.value) + + +_ACCESS_NOW: Final = datetime.now(timezone.utc) + + +@dataclass(frozen=True, slots=True) +class _ProjectRow: + project_id: str + team_id: str | None + + +def _membership(user_id: str, org_id: str, role: LitellmUserRoles) -> LiteLLM_OrganizationMembershipTable: + return LiteLLM_OrganizationMembershipTable( + user_id=user_id, organization_id=org_id, user_role=role.value, created_at=_ACCESS_NOW, updated_at=_ACCESS_NOW + ) + + +def _user(user_id: str, teams: tuple[str, ...] = (), admin_of: tuple[str, ...] = ()) -> LiteLLM_UserTable: + return LiteLLM_UserTable( + user_id=user_id, + teams=list(teams), + organization_memberships=[_membership(user_id, "org-a", LitellmUserRoles.INTERNAL_USER)] + + [_membership(user_id, org_id, LitellmUserRoles.ORG_ADMIN) for org_id in admin_of], + ) + + +_TEAMS: Final = { + team.team_id: team + for team in ( + LiteLLM_TeamTable( + team_id="team-a1", + organization_id="org-a", + members_with_roles=[Member(user_id="member", role="user"), Member(user_id="team-admin", role="admin")], + ), + LiteLLM_TeamTable(team_id="team-a2", organization_id="org-a"), + LiteLLM_TeamTable( + team_id="team-b1", + organization_id="org-b", + members_with_roles=[Member(user_id="org-admin-a-member-b1", role="user")], + ), + LiteLLM_TeamTable(team_id="team-orgless"), + ) +} +_PROJECTS: Final = ( + _ProjectRow("p-a1", "team-a1"), + _ProjectRow("p-a2", "team-a2"), + _ProjectRow("p-b1", "team-b1"), + _ProjectRow("p-orgless", "team-orgless"), + _ProjectRow("p-teamless", None), + _ProjectRow("p-deleted-team", "team-deleted"), +) +_USERS: Final = { + user.user_id: user + for user in ( + _user("member", teams=("team-a1",)), + _user("team-admin", teams=("team-a1",)), + _user("org-admin-a", admin_of=("org-a",)), + _user("org-admin-b", admin_of=("org-b",)), + _user("org-admin-a-member-b1", teams=("team-b1",), admin_of=("org-a",)), + ) +} + + +def _row_matches(row: object, where: Mapping[str, object]) -> bool: + return all(_condition_holds(row, key, condition) for key, condition in where.items()) + + +def _condition_holds(row: object, key: str, condition) -> bool: + if key == "OR": + return any(_row_matches(row, branch) for branch in condition) + if key == "litellm_team_table": + team = _TEAMS.get(getattr(row, "team_id")) + return team is not None and _row_matches(team, condition["is"]) + value = getattr(row, key) + return value in condition["in"] if isinstance(condition, dict) else value == condition + + +class _FakeTeamTable: + async def find_unique(self, where: Mapping[str, str], include: object = None) -> LiteLLM_TeamTable | None: + return _TEAMS.get(where["team_id"]) + + +class _FakeProjectTable: + async def find_unique(self, where: Mapping[str, str], include: object = None) -> _ProjectRow | None: + return next((p for p in _PROJECTS if p.project_id == where["project_id"]), None) + + async def find_many(self, where: Mapping[str, object] | None = None, include: object = None) -> list[_ProjectRow]: + return [p for p in _PROJECTS if where is None or _row_matches(p, where)] + + +class _FakeUserTable: + async def find_unique( + self, where: Mapping[str, str], include: Mapping[str, bool] | None = None + ) -> LiteLLM_UserTable | None: + user = _USERS.get(where["user_id"]) + if user is None or (include or {}).get("organization_memberships"): + return user + return user.model_copy(update={"organization_memberships": None}) + + +@pytest.fixture +def project_access_db(monkeypatch): + db = mock.MagicMock( + litellm_teamtable=_FakeTeamTable(), litellm_projecttable=_FakeProjectTable(), litellm_usertable=_FakeUserTable() + ) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock.MagicMock(db=db)) + + +async def _team_access_over_cached_users() -> TeamAccess: + cache = UserApiKeyCache() + for user in _USERS.values(): + await cache.async_set_cache(key=user.user_id, value=user) + return TeamAccess(org_roles=PrismaOrgRoles(None, cache, proxy_logging_obj)) + + +def _caller(user_id: str, role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER) -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_role=role, api_key="sk-caller", user_id=user_id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("user_id", "expected"), + [ + ("member", {"p-a1"}), + ("team-admin", {"p-a1"}), + ("org-admin-a", {"p-a1", "p-a2"}), + ("org-admin-b", {"p-b1"}), + ("org-admin-a-member-b1", {"p-a1", "p-a2", "p-b1"}), + ("unknown-user", set()), + ], +) +async def test_list_projects_scopes_to_teams_the_caller_can_view(project_access_db, user_id, expected): + from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects + + projects = await list_projects(user_api_key_dict=_caller(user_id)) + + assert {p.project_id for p in projects} == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) +async def test_list_projects_admin_view_sees_every_project(project_access_db, role): + from litellm_enterprise.proxy.management_endpoints.project_endpoints import list_projects + + projects = await list_projects(user_api_key_dict=_caller("someone", role)) + + assert {p.project_id for p in projects} == {p.project_id for p in _PROJECTS} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("user_id", "project_id"), + [ + ("member", "p-a1"), + ("team-admin", "p-a1"), + ("org-admin-a", "p-a1"), + ("org-admin-a", "p-a2"), + ("org-admin-a-member-b1", "p-b1"), + ], +) +async def test_project_info_allows_team_members_and_org_admins_of_the_team_org(project_access_db, user_id, project_id): + project = await project_info( + project_id=project_id, + user_api_key_dict=_caller(user_id), + team_access=await _team_access_over_cached_users(), + ) + + assert project.project_id == project_id + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("user_id", "project_id"), + [ + ("member", "p-a2"), + ("team-admin", "p-b1"), + ("org-admin-b", "p-a2"), + ("org-admin-a", "p-b1"), + ("org-admin-a", "p-orgless"), + ("org-admin-a", "p-teamless"), + ("member", "p-deleted-team"), + ], +) +async def test_project_info_denies_callers_who_cannot_view_the_team(project_access_db, user_id, project_id): + with pytest.raises(ProxyException) as exc_info: + await project_info( + project_id=project_id, + user_api_key_dict=_caller(user_id), + team_access=await _team_access_over_cached_users(), + ) + + assert str(exc_info.value.code) == "403" + assert "You don't have access to this project" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_project_info_missing_project_is_404_even_for_org_admins(project_access_db): + with pytest.raises(ProxyException) as exc_info: + await project_info( + project_id="p-missing", + user_api_key_dict=_caller("org-admin-a"), + team_access=await _team_access_over_cached_users(), + ) + + assert str(exc_info.value.code) == "404" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts new file mode 100644 index 00000000000..5cc144c94d3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/projectAccess.ts @@ -0,0 +1,46 @@ +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess"; +import { all_admin_roles, internalUserRoles, isAdminRole, isProxyAdminRole } from "@/utils/roles"; + +const TEAM_ADMIN_PROJECTS_PERMISSION = "projects"; + +export const projectReaderRoles: readonly string[] = [...all_admin_roles, "Org Admin", ...internalUserRoles]; + +export const canReadProjects = (userRole: string | null): boolean => projectReaderRoles.includes(userRole ?? ""); + +export interface ProjectsPageViewer { + readonly userRole: string; + readonly isOrgAdmin: boolean; + readonly isTeamAdmin: boolean; +} + +export const canViewProjectsPage = ({ userRole, isOrgAdmin, isTeamAdmin }: ProjectsPageViewer): boolean => + canReadProjects(userRole) && (isAdminRole(userRole) || isOrgAdmin || isTeamAdmin); + +export interface ProjectManager { + readonly userRole: string; + readonly isViewOnly: boolean; + readonly isTeamAdmin: boolean; + readonly teamAdminEditableFields: readonly string[]; +} + +export const canManageProjects = ({ + userRole, + isViewOnly, + isTeamAdmin, + teamAdminEditableFields, +}: ProjectManager): boolean => + !isViewOnly && + (isProxyAdminRole(userRole) || (isTeamAdmin && teamAdminEditableFields.includes(TEAM_ADMIN_PROJECTS_PERMISSION))); + +export const useCanManageProjects = (isTeamAdmin: boolean): boolean => { + const { userRole, isViewOnly } = useAuthorized(); + const { data: uiSettings } = useUISettings(); + return canManageProjects({ + userRole, + isViewOnly, + isTeamAdmin, + teamAdminEditableFields: parseTeamAdminEditableFields(uiSettings?.values), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts index 426abfe9bb6..cdaba106586 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts @@ -103,8 +103,18 @@ describe("useProjectDetails", () => { expect(global.fetch).not.toHaveBeenCalled(); }); - it("should not fetch when userRole is not an admin role", () => { - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" }); + it.each(["Internal User", "Org Admin"])("should fetch when userRole is %s", async (userRole) => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole }); + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("should not fetch when userRole cannot read projects", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "regular_user" }); const { result } = renderHook(() => useProjectDetails("proj-1"), { wrapper: makeWrapper(queryClient), }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts index 037baa18692..637e56d2dfe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.ts @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking"; -import { all_admin_roles } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { canReadProjects } from "./projectAccess"; import { ProjectResponse, projectKeys } from "./useProjects"; // ── Fetch function ─────────────────────────────────────────────────────────── @@ -37,7 +37,7 @@ export const useProjectDetails = (projectId?: string) => { return useQuery({ queryKey: projectKeys.detail(projectId!), queryFn: async () => fetchProjectDetails(accessToken!, projectId!), - enabled: Boolean(accessToken && projectId) && all_admin_roles.includes(userRole || ""), + enabled: Boolean(accessToken && projectId) && canReadProjects(userRole), // Seed from the list cache when available initialData: () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts index 39d1b28303d..a0238ea2121 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts @@ -115,8 +115,8 @@ describe("useProjects", () => { expect(global.fetch).not.toHaveBeenCalled(); }); - it("should fetch when userRole is an internal user role", async () => { - mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" }); + it.each(["Internal User", "Org Admin"])("should fetch when userRole is %s", async (userRole) => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole }); (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects }); const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts index c240dbb0170..7327e934680 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { all_admin_roles, internalUserRoles } from "@/utils/roles"; +import { canReadProjects } from "./projectAccess"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -42,8 +42,6 @@ export interface ProjectResponse { export const projectKeys = createQueryKeys("projects"); -const projectReaderRoles = [...all_admin_roles, ...internalUserRoles]; - // ── Fetch function ─────────────────────────────────────────────────────────── const fetchProjects = async (accessToken: string): Promise => { @@ -76,6 +74,6 @@ export const useProjects = () => { return useQuery({ queryKey: projectKeys.list({}), queryFn: async () => fetchProjects(accessToken!), - enabled: Boolean(accessToken) && projectReaderRoles.includes(userRole!), + enabled: Boolean(accessToken) && canReadProjects(userRole), }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx index d22ac0d8742..5d84488d739 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx @@ -14,6 +14,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeam: (id?: string) => mockUseTeam(id), })); +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockUseUISettings = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => mockUseUISettings(), +})); + vi.mock("./ProjectModals/EditProjectModal", () => ({ EditProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
: null), })); @@ -63,6 +73,8 @@ describe("ProjectDetail", () => { beforeEach(() => { vi.clearAllMocks(); mockUseTeam.mockReturnValue({ data: undefined, isLoading: false }); + mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false }); + mockUseUISettings.mockReturnValue({ data: { values: {} } }); }); describe("when loading", () => { @@ -171,6 +183,40 @@ describe("ProjectDetail", () => { expect(screen.getByTestId("edit-modal")).toBeInTheDocument(); }); + it.each([ + { who: "a proxy admin without the setting", role: "Admin", teamRole: "user", fields: [], visible: true }, + { + who: "the project team's admin without the setting", + role: "Internal User", + teamRole: "admin", + fields: [], + visible: false, + }, + { + who: "the project team's admin when the setting grants projects", + role: "Internal User", + teamRole: "admin", + fields: ["projects"], + visible: true, + }, + { + who: "a plain member of the project team when the setting grants projects", + role: "Internal User", + teamRole: "user", + fields: ["projects"], + visible: false, + }, + ])("should gate 'Edit Project' for $who", ({ role, teamRole, fields, visible }) => { + mockUseAuthorized.mockReturnValue({ userId: "caller", userRole: role, isViewOnly: false }); + mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: fields } } }); + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", members_with_roles: [{ user_id: "caller", role: teamRole }] }, + isLoading: false, + }); + renderWithProviders(); + expect(screen.queryByRole("button", { name: /edit project/i }) !== null).toBe(visible); + }); + it("should show 'No team assigned' when the project has no team", () => { mockUseProjectDetails.mockReturnValue({ data: { ...mockProject, team_id: null }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index 2585b67c81b..b9d8ef94cb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -1,5 +1,8 @@ +import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess"; import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { BarChart } from "@/components/shared/charts"; import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -24,6 +27,10 @@ const utilisationTone = (percent: number) => (percent >= 90 ? "over" : percent > export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { const { data: project, isLoading } = useProjectDetails(projectId); const { data: teamInfo } = useTeam(project?.team_id ?? undefined); + const { userId } = useAuthorized(); + const canEditProject = useCanManageProjects( + isUserTeamAdminForSingleTeam(teamInfo?.members_with_roles ?? null, userId ?? ""), + ); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const spend = project?.spend ?? 0; @@ -87,10 +94,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
- + {canEditProject && ( + + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx index 85201e2acd2..70530973c1d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx @@ -11,6 +11,11 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => mockUseTeams(), })); +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + vi.mock("@/components/organisms/create_key_button", () => ({ fetchTeamModels: vi.fn().mockResolvedValue([]), })); @@ -32,6 +37,7 @@ function FormWrapper() { describe("ProjectBaseForm", () => { beforeEach(() => { mockUseTeams.mockReturnValue({ data: [], isLoading: false }); + mockUseAuthorized.mockReturnValue({ accessToken: "token", userId: "admin-user", userRole: "Admin" }); }); it("should render", () => { @@ -83,6 +89,32 @@ describe("ProjectBaseForm", () => { expect(screen.getByText("Sales")).toBeInTheDocument(); }); + it("should offer a team admin only the teams they administer", async () => { + const user = userEvent.setup(); + mockUseAuthorized.mockReturnValue({ accessToken: "token", userId: "team-admin", userRole: "Internal User" }); + mockUseTeams.mockReturnValue({ + data: [ + { + team_id: "team-1", + team_alias: "Engineering", + models: [], + members_with_roles: [{ user_id: "team-admin", role: "admin" }], + }, + { + team_id: "team-2", + team_alias: "Sales", + models: [], + members_with_roles: [{ user_id: "team-admin", role: "user" }], + }, + ], + isLoading: false, + }); + renderWithProviders(); + await user.click(screen.getByLabelText("Team")); + expect(await screen.findByText("Engineering")).toBeInTheDocument(); + expect(screen.queryByText("Sales")).not.toBeInTheDocument(); + }); + it("should show the Max Budget field", () => { renderWithProviders(); expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx index b5603ba184c..c5d30e8490e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx @@ -24,6 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { teamsUserCanAssign } from "@/utils/roles"; const toOptionalNumber = (raw: string): number | undefined => { if (raw.trim() === "") return undefined; @@ -100,7 +101,7 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr form.setValue("models", []); }; - const teamOptions = (teams ?? []).map((team) => ({ + const teamOptions = (teamsUserCanAssign(teams ?? null, userRole, userId) ?? []).map((team) => ({ value: team.team_id, label: team.team_alias || team.team_id, sublabel: team.team_id, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx index 309da01b295..f23589d1993 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx @@ -15,6 +15,16 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => mockUseTeams(), })); +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockUseUISettings = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => mockUseUISettings(), +})); + // Stub modals and the detail page to keep tests focused on the list page vi.mock("./ProjectModals/CreateProjectModal", () => ({ CreateProjectModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
: null), @@ -76,6 +86,8 @@ describe("ProjectsPage", () => { beforeEach(() => { vi.clearAllMocks(); mockUseTeams.mockReturnValue({ data: [], isLoading: false }); + mockUseAuthorized.mockReturnValue({ userId: "admin-user", userRole: "Admin", isViewOnly: false }); + mockUseUISettings.mockReturnValue({ data: { values: {} } }); }); it("should render the Projects heading", () => { @@ -92,6 +104,41 @@ describe("ProjectsPage", () => { expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument(); }); + it.each([ + { who: "a proxy admin without the setting", role: "Admin", isViewOnly: false, fields: [], visible: true }, + { who: "a proxy admin viewer", role: "Admin", isViewOnly: true, fields: ["projects"], visible: false }, + { who: "a team admin without the setting", role: "Internal User", isViewOnly: false, fields: [], visible: false }, + { + who: "a team admin when the setting grants projects", + role: "Internal User", + isViewOnly: false, + fields: ["projects"], + visible: true, + }, + ])("should gate 'Create Project' for $who", ({ role, isViewOnly, fields, visible }) => { + mockUseAuthorized.mockReturnValue({ userId: "team-admin", userRole: role, isViewOnly }); + mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: fields } } }); + mockUseTeams.mockReturnValue({ + data: [{ team_id: "team-1", members_with_roles: [{ user_id: "team-admin", role: "admin" }] }], + isLoading: false, + }); + mockUseProjects.mockReturnValue({ data: [], isLoading: false }); + renderWithProviders(); + expect(screen.queryByRole("button", { name: /create project/i }) !== null).toBe(visible); + }); + + it("should hide 'Create Project' from a team member who administers no team even when the setting grants projects", () => { + mockUseAuthorized.mockReturnValue({ userId: "member", userRole: "Internal User", isViewOnly: false }); + mockUseUISettings.mockReturnValue({ data: { values: { team_admin_editable_team_fields: ["projects"] } } }); + mockUseTeams.mockReturnValue({ + data: [{ team_id: "team-1", members_with_roles: [{ user_id: "member", role: "user" }] }], + isLoading: false, + }); + mockUseProjects.mockReturnValue({ data: [], isLoading: false }); + renderWithProviders(); + expect(screen.queryByRole("button", { name: /create project/i })).not.toBeInTheDocument(); + }); + it("should render the projects table", () => { mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx index cb595916041..2264f9ec2c8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx @@ -1,6 +1,9 @@ +import { useCanManageProjects } from "@/app/(dashboard)/hooks/projects/projectAccess"; import { Page, PageContent } from "@/components/shared/Page"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { Folder, Plus, SearchIcon, X } from "lucide-react"; import { parseAsString, useQueryState } from "nuqs"; import { useMemo, useState } from "react"; @@ -15,6 +18,8 @@ import { useClearProjectKeysTableState, useProjectsTableState } from "./useProje export function ProjectsPage() { const { data: projects, isLoading } = useProjects(); const { data: teams, isLoading: isTeamsLoading } = useTeams(); + const { userId } = useAuthorized(); + const canCreateProject = useCanManageProjects(isUserTeamAdminForAnyTeam(teams ?? null, userId ?? "")); const [selectedProjectId, setSelectedProjectId] = useQueryState( "project", @@ -64,12 +69,14 @@ export function ProjectsPage() { Projects Manage projects within your teams - - - + {canCreateProject && ( + + + + )} diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 2c4a129e4cf..cdb74c73a80 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -13,7 +13,7 @@ vi.mock("../utils/roles", async (importOriginal) => { rolesWithWriteAccess: ["admin", "internal"], rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"], isAdminRole: (role: string) => role === "admin" || role === "admin_viewer", - isUserTeamAdminForAnyTeam: () => false, + isUserTeamAdminForAnyTeam: actual.isUserTeamAdminForAnyTeam, }; }); @@ -24,7 +24,7 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), })); -const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => { +const { mockUseAuthorized, mockUseOrganizations, mockUseTeams } = vi.hoisted(() => { const mockUseAuthorized = vi.fn(() => ({ userId: "test-user-id", accessToken: "test-access-token", @@ -43,7 +43,13 @@ const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => { error: null, })); - return { mockUseAuthorized, mockUseOrganizations }; + const mockUseTeams = vi.fn(() => ({ + data: [] as Array<{ members_with_roles: Array<{ user_id: string; role: string }> }>, + isLoading: false, + error: null, + })); + + return { mockUseAuthorized, mockUseOrganizations, mockUseTeams }; }); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ @@ -55,7 +61,7 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ })); vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ - useTeams: () => ({ data: [], isLoading: false, error: null }), + useTeams: mockUseTeams, })); vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => { @@ -103,6 +109,30 @@ const placementsOf = (page: string): string[] => ), ]); +const teamAdminAuthorization = { + userId: "team-admin-user-id", + accessToken: "test-access-token", + userRole: "internal", + isViewOnly: false, + token: "test-token", + userEmail: "teamadmin@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + +const teamMemberAuthorization = { + userId: "team-member-user-id", + accessToken: "test-access-token", + userRole: "internal", + isViewOnly: false, + token: "test-token", + userEmail: "teamuser@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + describe("Sidebar (leftnav)", () => { const defaultProps = { collapsed: false, @@ -111,6 +141,12 @@ describe("Sidebar (leftnav)", () => { afterEach(() => { mockUseAuthorized.mockReset(); mockUseOrganizations.mockReset(); + mockUseTeams.mockReset(); + mockUseTeams.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); mockUseThemeImpl = unbrandedTheme; navState.pathname = "/ui/api-keys"; }); @@ -547,6 +583,76 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Organizations")).toBeInTheDocument(); }); + it("shows Projects to an internal user who administers a team", () => { + mockUseAuthorized.mockReturnValue(teamAdminAuthorization); + mockUseTeams.mockReturnValue({ + data: [ + { + members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }], + }, + ], + isLoading: false, + error: null, + }); + + renderWithProviders(); + + expect(screen.getByRole("link", { name: /Projects/ })).toBeInTheDocument(); + }); + + it("hides Projects when the feature flag is disabled for a team admin", () => { + mockUseAuthorized.mockReturnValue(teamAdminAuthorization); + mockUseTeams.mockReturnValue({ + data: [ + { + members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }], + }, + ], + isLoading: false, + error: null, + }); + + renderWithProviders(); + + expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument(); + }); + + it("hides Projects from an internal user who is not a team admin", () => { + mockUseAuthorized.mockReturnValue(teamMemberAuthorization); + + renderWithProviders(); + + expect(screen.queryByRole("link", { name: /Projects/ })).not.toBeInTheDocument(); + }); + + it.each([ + { allowlist: ["teams"], visible: false }, + { allowlist: ["teams", "projects"], visible: true }, + ])("applies the internal-user page allowlist $allowlist to Projects for team admins", ({ allowlist, visible }) => { + mockUseAuthorized.mockReturnValue(teamAdminAuthorization); + mockUseTeams.mockReturnValue({ + data: [ + { + members_with_roles: [{ user_id: "team-admin-user-id", role: "admin" }], + }, + ], + isLoading: false, + error: null, + }); + + renderWithProviders(); + + expect(screen.queryByRole("link", { name: /Projects/ }) !== null).toBe(visible); + }); + + it("shows Projects to a user whose global role is org admin", () => { + mockUseAuthorized.mockReturnValue({ ...teamMemberAuthorization, userRole: "Org Admin" }); + + renderWithProviders(); + + expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects"); + }); + it("marks the nav item for the current route active", () => { navState.pathname = "/ui/logs"; renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 1567b1b189e..4cba12c9f7e 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,3 +1,4 @@ +import { canViewProjectsPage, projectReaderRoles } from "@/app/(dashboard)/hooks/projects/projectAccess"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; @@ -263,7 +264,7 @@ const menuGroups: MenuGroup[] = [ ), icon: , - roles: all_admin_roles, + roles: [...projectReaderRoles], }, { key: "users", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, { @@ -427,7 +428,7 @@ const prettify = (key: string): string => .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); -const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key)); +export const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key)); // Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => { @@ -500,7 +501,11 @@ const Sidebar_: React.FC = ({ if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); return true; } - if (item.key === "projects" && !enableProjectsUI) return false; + if ( + item.key === "projects" && + !(enableProjectsUI && canViewProjectsPage({ userRole, isOrgAdmin, isTeamAdmin })) + ) + return false; if ( !isAdmin && item.key === "agents" && diff --git a/ui/litellm-dashboard/src/components/page_utils.test.ts b/ui/litellm-dashboard/src/components/page_utils.test.ts index a38c56d8681..6f55326a5be 100644 --- a/ui/litellm-dashboard/src/components/page_utils.test.ts +++ b/ui/litellm-dashboard/src/components/page_utils.test.ts @@ -195,6 +195,13 @@ describe("Page Utils - LeftNav Sync", () => { expect(pageKeys.length, "All page keys should be unique (no duplicates)").toBe(uniquePageKeys.size); }); + it("offers Projects in the internal-user page picker so team admins can be granted it", () => { + expect(getAvailablePages().find((page) => page.page === "projects")).toMatchObject({ + label: "Projects", + group: "ACCESS CONTROL", + }); + }); + it("should match the structure expected by PageVisibilitySettings component", () => { const availablePages = getAvailablePages(); diff --git a/ui/litellm-dashboard/src/components/page_utils.ts b/ui/litellm-dashboard/src/components/page_utils.ts index c682b2db089..2339ffc4006 100644 --- a/ui/litellm-dashboard/src/components/page_utils.ts +++ b/ui/litellm-dashboard/src/components/page_utils.ts @@ -2,7 +2,7 @@ * Utility functions for working with navigation pages */ -import { menuGroups } from "./leftnav"; +import { labelText, menuGroups } from "./leftnav"; import { pageDescriptions, PageMetadata } from "./page_metadata"; import { internalUserRoles } from "@/utils/roles"; @@ -43,10 +43,9 @@ export const getAvailablePages = (): PageMetadata[] => { item.page !== "settings" && isPageAccessibleToInternalUsers(item.roles) ) { - const label = typeof item.label === "string" ? item.label : item.key; pages.push({ page: item.page, - label: label, + label: labelText(item), group: group.groupLabel, description: pageDescriptions[item.page] || "No description available", }); @@ -54,14 +53,13 @@ export const getAvailablePages = (): PageMetadata[] => { // Add children items (also skip those internal users cannot access) if (item.children) { - const parentLabel = typeof item.label === "string" ? item.label : item.key; + const parentLabel = labelText(item); 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, + label: labelText(child), group: `${group.groupLabel} > ${parentLabel}`, description: pageDescriptions[child.page] || "No description available", });