From c631708df636ff7544bfa75d3e897471da04ba06 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 15:38:31 -0800 Subject: [PATCH 01/15] feat: add opt-in scope_user_search_to_org flag for /user/filter/ui PR #22722 made org-scoping unconditional on /user/filter/ui, which broke team admins who aren't org admins (403 when searching users to add). This makes org-scoping opt-in via a new UI Settings toggle, restoring open search by default. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 156 ++++++---- .../proxy_setting_endpoints.py | 6 + .../test_internal_user_endpoints.py | 268 +++++++++++++++++- .../AdminSettings/UISettings/UISettings.tsx | 35 +++ .../common_components/user_search_modal.tsx | 5 + .../src/components/networking.tsx | 13 +- .../src/components/team/TeamInfo.tsx | 1 + 7 files changed, 416 insertions(+), 68 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 92862ed9dc4..d43efd81386 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1836,6 +1836,10 @@ async def ui_view_users( user_email: Optional[str] = fastapi.Query( default=None, description="User email in the request parameters" ), + team_id: Optional[str] = fastapi.Query( + default=None, + description="Team ID — used when a team admin searches for users to add to their team", + ), page: int = fastapi.Query( default=1, description="Page number for pagination", ge=1 ), @@ -1847,20 +1851,19 @@ async def ui_view_users( """ Filter users based on partial match of user_id or email with pagination. - - Proxy admins: receive all matching users. - - Organization admins: receive only users in their own organization(s). - - Other roles: access denied (403). + Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag + (stored in the ``litellm_uisettings`` table): - Args: - user_id (Optional[str]): Partial user ID to search for - user_email (Optional[str]): Partial email to search for - page (int): Page number for pagination (starts at 1) - page_size (int): Number of items per page (max 100) - user_api_key_dict (UserAPIKeyAuth): User authentication information - - Returns: - List of matching user records (LiteLLM_UserTableFiltered), scoped by org for org admins. + * **Flag OFF (default):** any authenticated user can search all users. + * **Flag ON:** + - Proxy admins see all users. + - Org admins see only users in their org(s). + - Team admins for an org-bound team see users in that org. + - Others receive a 403. """ + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + ) from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, @@ -1871,51 +1874,84 @@ async def ui_view_users( raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Restrict by caller role: proxy admin sees all; org admin sees only their org(s); others 403 - is_proxy_admin = _user_has_admin_view(user_api_key_dict) - if not is_proxy_admin: - if user_api_key_dict.user_id is None: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) - try: - caller_user = await get_user_object( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - user_id_upsert=False, - proxy_logging_obj=proxy_logging_obj, - ) - except ValueError: - # get_user_object raises ValueError when user not found (user_id_upsert=False) - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) - if caller_user is None: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) - org_admin_org_ids = [ - m.organization_id - for m in (caller_user.organization_memberships or []) - if m.user_role == LitellmUserRoles.ORG_ADMIN.value - ] - if not org_admin_org_ids: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) + # Read the scope_user_search_to_org flag from the DB + ui_settings_row = ( + await prisma_client.db.litellm_uisettings.find_unique( + where={"id": "ui_settings"} + ) + ) + scope_flag = False + if ui_settings_row is not None: + settings_json = ui_settings_row.settings or {} # type: ignore[union-attr] + scope_flag = bool(settings_json.get("scope_user_search_to_org", False)) + + org_filter_ids: Optional[List[str]] = None + + if scope_flag: + is_proxy_admin = _user_has_admin_view(user_api_key_dict) + if not is_proxy_admin: + # Try to resolve org admin memberships + caller_user = None + if user_api_key_dict.user_id is not None: + try: + caller_user = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except ValueError: + caller_user = None + + org_admin_org_ids: List[str] = [] + if caller_user is not None: + org_admin_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + + if org_admin_org_ids: + org_filter_ids = org_admin_org_ids + elif team_id is not None: + # Look up the team to check if it belongs to an org + team_row = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) + if team_row is not None: + team_obj = LiteLLM_TeamTable(**team_row.model_dump()) + if _is_user_team_admin(user_api_key_dict, team_obj): + if team_obj.organization_id: + org_filter_ids = [team_obj.organization_id] + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." + }, + ) + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) # Calculate offset for pagination skip = (page - 1) * page_size @@ -1935,10 +1971,10 @@ async def ui_view_users( "mode": "insensitive", # Case-insensitive search } - # Org admins: only users in their org(s) - if not is_proxy_admin: + # Apply org filter when scope_user_search_to_org is ON and caller is not proxy admin + if org_filter_ids is not None: where_conditions["organization_memberships"] = { - "some": {"organization_id": {"in": org_admin_org_ids}} + "some": {"organization_id": {"in": org_filter_ids}} } # Query users with pagination and filters diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 2f7f81a703e..9603f55e489 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -124,6 +124,11 @@ class UISettings(BaseModel): description="If true, team admins are exempt from the vector stores disable restriction (only takes effect when disable_vector_stores_for_internal_users is true).", ) + scope_user_search_to_org: bool = Field( + default=False, + description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -143,6 +148,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", + "scope_user_search_to_org", } # Flags that must be synced from the persisted UISettings into diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 16b5feb108a..6316d13b98f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -54,6 +54,12 @@ async def test_ui_view_users_with_null_email(mocker, caplog): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + # Flag OFF by default — no settings row + async def mock_find_unique_settings(*args, **kwargs): + return None + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) # Proxy admin: no org filter, no get_user_object call @@ -63,6 +69,7 @@ async def test_ui_view_users_with_null_email(mocker, caplog): ), user_id="test_user", user_email=None, + team_id=None, page=1, page_size=50, ) @@ -83,6 +90,12 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): return [] mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag OFF by default + async def mock_find_unique_settings(*args, **kwargs): + return None + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) await ui_view_users( @@ -91,6 +104,7 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): ), user_id=None, user_email="foo", + team_id=None, page=1, page_size=50, ) @@ -99,8 +113,8 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): @pytest.mark.asyncio async def test_ui_view_users_org_admin_filtered_by_org(mocker): """ - Org admin: find_many is called with organization_memberships filter so only users - in the caller's org(s) are returned. + Org admin with scope_user_search_to_org ON: find_many is called with + organization_memberships filter so only users in the caller's org(s) are returned. """ from litellm.proxy._types import LiteLLM_OrganizationMembershipTable @@ -116,6 +130,16 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): return [] mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) @@ -143,6 +167,7 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): user_api_key_dict=UserAPIKeyAuth(user_id="org-admin", user_role=None), user_id=None, user_email="u", + team_id=None, page=1, page_size=50, ) @@ -153,11 +178,21 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): @pytest.mark.asyncio async def test_ui_view_users_non_org_admin_returns_403(mocker): """ - Caller is not proxy admin and not org admin: endpoint returns 403. + Flag ON, caller is not proxy admin and not org admin, no team_id: endpoint returns 403. """ from fastapi import HTTPException mock_prisma_client = mocker.MagicMock() + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) @@ -179,12 +214,237 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), user_id=None, user_email="u", + team_id=None, page=1, page_size=50, ) assert exc_info.value.status_code == 403 - assert "Only proxy admins and organization admins" in str(exc_info.value.detail) + assert "scope_user_search_to_org is enabled" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_off_internal_user_can_search(mocker): + """ + Flag OFF (default): any authenticated user can search all users without org filtering. + """ + mock_prisma_client = mocker.MagicMock() + + async def mock_find_many(*args, **kwargs): + where = kwargs.get("where") or {} + assert "organization_memberships" not in where + return [] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag OFF — no settings row + async def mock_find_unique_settings(*args, **kwargs): + return None + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + response = await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), + user_id=None, + user_email="foo", + team_id=None, + page=1, + page_size=50, + ) + + assert response == [] + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_on_team_admin_org_team(mocker): + """ + Flag ON, team admin for org-bound team: org filter is applied using team's org. + """ + from litellm.proxy._types import LiteLLM_TeamTable, Member + + mock_prisma_client = mocker.MagicMock() + org_id = "org-456" + tid = "team-789" + + async def mock_find_many(*args, **kwargs): + where = kwargs.get("where") or {} + assert "organization_memberships" in where + assert where["organization_memberships"] == { + "some": {"organization_id": {"in": [org_id]}} + } + return [] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + + # Team lookup + mock_team_row = mocker.MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": tid, + "team_alias": "test-team", + "organization_id": org_id, + "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], + "admins": [], + "members": [], + "blocked": False, + } + + async def mock_find_unique_team(*args, **kwargs): + return mock_team_row + + mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller is not org admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + response = await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="team-admin-user", user_role=None), + user_id=None, + user_email="u", + team_id=tid, + page=1, + page_size=50, + ) + + assert response == [] + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_on_team_admin_non_org_team_403(mocker): + """ + Flag ON, team admin for non-org team: returns 403. + """ + from fastapi import HTTPException + + mock_prisma_client = mocker.MagicMock() + tid = "team-no-org" + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + + # Team lookup — no organization_id + mock_team_row = mocker.MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": tid, + "team_alias": "no-org-team", + "organization_id": None, + "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], + "admins": [], + "members": [], + "blocked": False, + } + + async def mock_find_unique_team(*args, **kwargs): + return mock_team_row + + mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller is not org admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + with pytest.raises(HTTPException) as exc_info: + await ui_view_users( + user_api_key_dict=UserAPIKeyAuth( + user_id="team-admin-user", user_role=None + ), + user_id=None, + user_email="u", + team_id=tid, + page=1, + page_size=50, + ) + + assert exc_info.value.status_code == 403 + assert "not part of an organization" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_on_non_admin_no_team_id_403(mocker): + """ + Flag ON, non-admin caller without team_id: returns 403. + """ + from fastapi import HTTPException + + mock_prisma_client = mocker.MagicMock() + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller is not org admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + with pytest.raises(HTTPException) as exc_info: + await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), + user_id=None, + user_email="u", + team_id=None, + page=1, + page_size=50, + ) + + assert exc_info.value.status_code == 403 + assert "scope_user_search_to_org is enabled" in str(exc_info.value.detail) def test_user_daily_activity_types(): 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 dfc66d3484d..a22c78c9430 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -23,6 +23,7 @@ export default function UISettings() { const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins; const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users; const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; + const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -167,6 +168,20 @@ export default function UISettings() { ); }; + const handleToggleScopeUserSearch = (checked: boolean) => { + updateSettings( + { scope_user_search_to_org: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + return ( {isLoading ? ( @@ -347,6 +362,26 @@ export default function UISettings() { + {/* Scope user search to organization */} + + + + Scope user search to organization + + {scopeUserSearchProperty?.description ?? + "If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."} + + + + + + {/* Page Visibility for Internal Users */} = ({ @@ -52,6 +53,7 @@ const UserSearchModal: React.FC = ({ { label: "user", value: "user", description: "User role. Can view team info, but not manage it." }, ], defaultRole = "user", + teamId, }) => { const [form] = Form.useForm(); const [userOptions, setUserOptions] = useState([]); @@ -69,6 +71,9 @@ const UserSearchModal: React.FC = ({ try { const params = new URLSearchParams(); params.append(fieldName, searchText); + if (teamId) { + params.append("team_id", teamId); + } if (accessToken == null) { return; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 91454d8d8b3..8d1e8ce81cd 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2473,14 +2473,19 @@ export const allEndUsersCall = async (accessToken: string) => { export const userFilterUICall = async (accessToken: string, params: URLSearchParams) => { try { - let url = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`; - + const base = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`; + const queryParams = new URLSearchParams(); if (params.get("user_email")) { - url += `?user_email=${params.get("user_email")}`; + queryParams.append("user_email", params.get("user_email")!); } if (params.get("user_id")) { - url += `?user_id=${params.get("user_id")}`; + queryParams.append("user_id", params.get("user_id")!); } + if (params.get("team_id")) { + queryParams.append("team_id", params.get("team_id")!); + } + const qs = queryParams.toString(); + const url = qs ? `${base}?${qs}` : base; const response = await fetch(url, { method: "GET", diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 62208b4186d..7d350b281e4 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1303,6 +1303,7 @@ const TeamInfoView: React.FC = ({ onCancel={() => setIsAddMemberModalVisible(false)} onSubmit={handleMemberCreate} accessToken={accessToken} + teamId={teamId} /> {/* Delete Member Confirmation Modal */} From af61132a3f4331a2ff352d38ec92b03835a126e8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 15:56:43 -0800 Subject: [PATCH 02/15] refactor: use DualCache for UI settings reads and get_team_object for team lookup - Add get_ui_settings_cached() helper that reads from DualCache first, falls back to DB, and populates cache on miss. - Update update_ui_settings() to set cache after DB write so subsequent reads see new values immediately. - Replace raw prisma_client.db.litellm_teamtable.find_unique with the existing get_team_object helper which uses DualCache. - Update all tests to mock get_ui_settings_cached and get_team_object instead of raw DB calls. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 54 ++++--- .../proxy_setting_endpoints.py | 52 +++++++ .../test_internal_user_endpoints.py | 140 ++++++++---------- 3 files changed, 140 insertions(+), 106 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index d43efd81386..8d9da86aab2 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -30,7 +30,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity, get_daily_activity_aggregated, ) -from litellm.proxy.auth.auth_checks import get_user_object +from litellm.proxy.auth.auth_checks import get_team_object, get_user_object from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -1869,21 +1869,17 @@ async def ui_view_users( proxy_logging_obj, user_api_key_cache, ) + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + get_ui_settings_cached, + ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Read the scope_user_search_to_org flag from the DB - ui_settings_row = ( - await prisma_client.db.litellm_uisettings.find_unique( - where={"id": "ui_settings"} - ) - ) - scope_flag = False - if ui_settings_row is not None: - settings_json = ui_settings_row.settings or {} # type: ignore[union-attr] - scope_flag = bool(settings_json.get("scope_user_search_to_org", False)) + # Read the scope_user_search_to_org flag (cached) + ui_settings = await get_ui_settings_cached() + scope_flag = bool(ui_settings.get("scope_user_search_to_org", False)) org_filter_ids: Optional[List[str]] = None @@ -1915,27 +1911,29 @@ async def ui_view_users( if org_admin_org_ids: org_filter_ids = org_admin_org_ids elif team_id is not None: - # Look up the team to check if it belongs to an org - team_row = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id} - ) - if team_row is not None: - team_obj = LiteLLM_TeamTable(**team_row.model_dump()) - if _is_user_team_admin(user_api_key_dict, team_obj): - if team_obj.organization_id: - org_filter_ids = [team_obj.organization_id] - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." - }, - ) + # Look up the team via cached helper + try: + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + if _is_user_team_admin(user_api_key_dict, team_obj): + if team_obj.organization_id: + org_filter_ids = [team_obj.organization_id] else: raise HTTPException( status_code=403, detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." }, ) else: diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 9603f55e489..04b524ebbb7 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -980,6 +980,48 @@ async def get_in_product_nudges(): return InProductNudgeResponse(is_claude_code_enabled=False) +UI_SETTINGS_CACHE_KEY = "ui_settings:settings_dict" + + +async def get_ui_settings_cached() -> Dict[str, Any]: + """ + Return the persisted UI settings dict, using DualCache for reads. + + Cache hit → return cached dict immediately. + Cache miss → read from DB, populate cache, return dict. + """ + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + # 1. Try cache + cached = await user_api_key_cache.async_get_cache(key=UI_SETTINGS_CACHE_KEY) + if cached is not None and isinstance(cached, dict): + return cached + + # 2. Fallback to DB + if prisma_client is None: + return {} + + db_record = await prisma_client.db.litellm_uisettings.find_unique( + where={"id": "ui_settings"} + ) + ui_settings: Dict[str, Any] = {} + if db_record and db_record.ui_settings: + raw = db_record.ui_settings + ui_settings = json.loads(raw) if isinstance(raw, str) else dict(raw) + + # Sanitize + ui_settings = { + k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS + } + + # 3. Populate cache + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=ui_settings + ) + + return ui_settings + + @router.get( "/get/ui_settings", tags=["UI Settings"], @@ -1108,6 +1150,16 @@ async def update_ui_settings( general_settings.update(_flags_to_sync) + # Invalidate + set DualCache so subsequent reads see the new values immediately + from litellm.proxy.proxy_server import user_api_key_cache + + sanitized = { + k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS + } + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=sanitized + ) + return { "message": "UI settings updated successfully", "status": "success", diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 6316d13b98f..16d16c040a3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -54,11 +54,11 @@ async def test_ui_view_users_with_null_email(mocker, caplog): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many - # Flag OFF by default — no settings row - async def mock_find_unique_settings(*args, **kwargs): - return None - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + # Flag OFF by default + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -92,10 +92,10 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many # Flag OFF by default - async def mock_find_unique_settings(*args, **kwargs): - return None - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) await ui_view_users( @@ -132,13 +132,10 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} - - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -185,13 +182,10 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): mock_prisma_client = mocker.MagicMock() # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} - - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -237,11 +231,11 @@ async def test_ui_view_users_flag_off_internal_user_can_search(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many - # Flag OFF — no settings row - async def mock_find_unique_settings(*args, **kwargs): - return None - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + # Flag OFF + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) response = await ui_view_users( @@ -261,7 +255,7 @@ async def test_ui_view_users_flag_on_team_admin_org_team(mocker): """ Flag ON, team admin for org-bound team: org filter is applied using team's org. """ - from litellm.proxy._types import LiteLLM_TeamTable, Member + from litellm.proxy._types import LiteLLM_TeamTableCachedObj mock_prisma_client = mocker.MagicMock() org_id = "org-456" @@ -278,30 +272,26 @@ async def test_ui_view_users_flag_on_team_admin_org_team(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row + # Mock get_team_object + team_obj = LiteLLM_TeamTableCachedObj( + team_id=tid, + team_alias="test-team", + organization_id=org_id, + members_with_roles=[{"user_id": "team-admin-user", "role": "admin"}], + ) - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + async def mock_get_team_object(*args, **kwargs): + return team_obj - # Team lookup - mock_team_row = mocker.MagicMock() - mock_team_row.model_dump.return_value = { - "team_id": tid, - "team_alias": "test-team", - "organization_id": org_id, - "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], - "admins": [], - "members": [], - "blocked": False, - } - - async def mock_find_unique_team(*args, **kwargs): - return mock_team_row - - mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_team_object", + side_effect=mock_get_team_object, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -337,35 +327,32 @@ async def test_ui_view_users_flag_on_team_admin_non_org_team_403(mocker): Flag ON, team admin for non-org team: returns 403. """ from fastapi import HTTPException + from litellm.proxy._types import LiteLLM_TeamTableCachedObj mock_prisma_client = mocker.MagicMock() tid = "team-no-org" # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row + # Mock get_team_object — team has no organization_id + team_obj = LiteLLM_TeamTableCachedObj( + team_id=tid, + team_alias="no-org-team", + organization_id=None, + members_with_roles=[{"user_id": "team-admin-user", "role": "admin"}], + ) - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + async def mock_get_team_object(*args, **kwargs): + return team_obj - # Team lookup — no organization_id - mock_team_row = mocker.MagicMock() - mock_team_row.model_dump.return_value = { - "team_id": tid, - "team_alias": "no-org-team", - "organization_id": None, - "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], - "admins": [], - "members": [], - "blocked": False, - } - - async def mock_find_unique_team(*args, **kwargs): - return mock_team_row - - mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_team_object", + side_effect=mock_get_team_object, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -409,13 +396,10 @@ async def test_ui_view_users_flag_on_non_admin_no_team_id_403(mocker): mock_prisma_client = mocker.MagicMock() # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} - - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) From c11477fb6fa5de8836bab63665662de054a09fbf Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 16:25:52 -0800 Subject: [PATCH 03/15] fix: add 10-minute TTL to UI settings cache and refresh on GET All async_set_cache calls for UI settings now use a 600s TTL so entries auto-expire in multi-instance deployments without Redis. The GET /ui_settings endpoint also refreshes the cache after reading from DB, ensuring the admin who just toggled the flag sees fresh values propagated to other code paths immediately. Co-Authored-By: Claude Opus 4.6 --- .../ui_crud_endpoints/proxy_setting_endpoints.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 04b524ebbb7..076a2c3bffd 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -981,6 +981,7 @@ async def get_in_product_nudges(): UI_SETTINGS_CACHE_KEY = "ui_settings:settings_dict" +UI_SETTINGS_CACHE_TTL = 600 # 10 minutes async def get_ui_settings_cached() -> Dict[str, Any]: @@ -1014,9 +1015,9 @@ async def get_ui_settings_cached() -> Dict[str, Any]: k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS } - # 3. Populate cache + # 3. Populate cache with TTL await user_api_key_cache.async_set_cache( - key=UI_SETTINGS_CACHE_KEY, value=ui_settings + key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL ) return ui_settings @@ -1066,6 +1067,13 @@ async def get_ui_settings(): general_settings.update(_flags_to_sync) + # Refresh DualCache so other code paths (e.g. /user/filter/ui) see fresh values + from litellm.proxy.proxy_server import user_api_key_cache + + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL + ) + # Build config-like object for schema helper config: Dict[str, Any] = {"litellm_settings": {"ui_settings": ui_settings}} @@ -1157,7 +1165,7 @@ async def update_ui_settings( k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS } await user_api_key_cache.async_set_cache( - key=UI_SETTINGS_CACHE_KEY, value=sanitized + key=UI_SETTINGS_CACHE_KEY, value=sanitized, ttl=UI_SETTINGS_CACHE_TTL ) return { From cb7da3044ddcfd193336fd7622a6c34870bdf08a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 17:40:29 -0800 Subject: [PATCH 04/15] feat: allow org admins to view Internal Users page and invite users Org admins can now see the Internal Users page in the left nav, view users scoped to their organization(s), and invite new users who are automatically added to the org. Proxy admins remain unaffected. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 12 ++ .../src/app/(dashboard)/users/page.tsx | 22 ++- .../src/components/CreateUserButton.tsx | 16 +- .../src/components/leftnav.tsx | 4 +- .../src/components/networking.tsx | 5 + .../src/components/view_users.tsx | 163 +++++++++++------- 6 files changed, 155 insertions(+), 67 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 92862ed9dc4..1ddb7b47c1d 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1502,6 +1502,10 @@ async def get_users( sort_order: str = fastapi.Query( default="asc", description="Sort order ('asc' or 'desc')" ), + organization_id: Optional[str] = fastapi.Query( + default=None, + description="Filter users by organization membership. Comma-separated for multiple orgs.", + ), ): """ Get a paginated list of users with filtering and sorting options. @@ -1576,6 +1580,14 @@ async def get_users( "in": sso_id_list, } + if organization_id is not None and isinstance(organization_id, str): + org_id_list = [ + oid.strip() for oid in organization_id.split(",") if oid.strip() + ] + where_conditions["organization_memberships"] = { + "some": {"organization_id": {"in": org_id_list}} + } + ## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}} where_conditions = {k: v for k, v in where_conditions.items() if v is not None} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx index 5ab6920b283..ae7a0b9767a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx @@ -3,13 +3,32 @@ import ViewUserDashboard from "@/components/view_users"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import { useState } from "react"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { isProxyAdminRole } from "@/utils/roles"; +import { useState, useMemo } from "react"; +import { Organization } from "@/components/networking"; const UsersPage = () => { const { accessToken, userRole, userId, token } = useAuthorized(); const [keys, setKeys] = useState([]); const { teams } = useTeams(); + const { data: organizations } = useOrganizations(); + + // Compute org IDs where the user is an org_admin, but only if they're NOT a proxy admin + const orgAdminOrgIds = useMemo(() => { + if (!userId || !organizations || !userRole) return null; + // Proxy admins see all users — no org filtering + if (isProxyAdminRole(userRole)) return null; + + const adminOrgIds = organizations + .filter((org: Organization) => + org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin") + ) + .map((org: Organization) => org.organization_id); + + return adminOrgIds.length > 0 ? adminOrgIds : null; + }, [userId, organizations, userRole]); return ( { userID={userId} teams={teams as any} setKeys={setKeys} + orgAdminOrgIds={orgAdminOrgIds} /> ); }; diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index d463ced08f6..09978a1c93e 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -19,6 +19,7 @@ import { getProxyUISettings, invitationCreateCall, modelAvailableCall, + organizationMemberAddCall, userCreateCall, } from "./networking"; import OnboardingModal, { InvitationLink } from "./onboarding_link"; @@ -44,6 +45,7 @@ interface CreateuserProps { possibleUIRoles: null | Record>; onUserCreated?: (userId: string) => void; isEmbedded?: boolean; + organizationId?: string | null; } // Define an interface for the UI settings @@ -55,7 +57,7 @@ interface UISettings { } export const CreateUserButton: React.FC = ({ - userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false }) => { + userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationId }) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -112,6 +114,18 @@ export const CreateUserButton: React.FC = ({ setApiuser(true); const user_id = response.data?.user_id || response.user_id; + // Auto-add user to the org admin's organization + if (organizationId && user_id) { + try { + await organizationMemberAddCall(accessToken, organizationId, { + role: "internal_user", + user_id: user_id, + }); + } catch (orgError) { + console.error("Failed to add user to organization:", orgError); + } + } + if (onUserCreated && isEmbedded) { onUserCreated(user_id); form.resetFields(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index fa35a566deb..d01fc06bc05 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -443,8 +443,8 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse children: item.children ? filterItemsByRole(item.children) : undefined, })) .filter((item) => { - // Special handling for organizations menu item - allow org_admins - if (item.key === "organizations") { + // Special handling for organizations and users menu items - allow org_admins + if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 91454d8d8b3..1dd9afc698e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1104,6 +1104,7 @@ export const userListCall = async ( sso_user_id: string | null = null, sortBy: string | null = null, sortOrder: "asc" | "desc" | null = null, + organizationIds: string[] | null = null, ) => { /** * Get all available teams on proxy @@ -1151,6 +1152,10 @@ export const userListCall = async ( queryParams.append("sort_order", sortOrder); } + if (organizationIds && organizationIds.length > 0) { + queryParams.append("organization_id", organizationIds.join(",")); + } + const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 576a1a84be3..49a55f393ff 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -16,7 +16,7 @@ import { import OnboardingModal, { InvitationLink } from "./onboarding_link"; import { updateExistingKeys } from "@/utils/dataUtils"; -import { isAdminRole } from "@/utils/roles"; +import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Typography } from "antd"; @@ -39,6 +39,7 @@ interface ViewUserDashboardProps { userID: string | null; teams: any[] | null; setKeys: React.Dispatch>; + orgAdminOrgIds?: string[] | null; } interface FilterState { @@ -69,7 +70,8 @@ const initialFilters: FilterState = { sort_order: "desc", }; -const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams }) => { +const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams, orgAdminOrgIds }) => { + const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; const queryClient = useQueryClient(); const [currentPage, setCurrentPage] = useState(1); const [editModalVisible, setEditModalVisible] = useState(false); @@ -245,7 +247,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke }; const userListQuery = useQuery({ - queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage }], + queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }], queryFn: async () => { if (!accessToken) throw new Error("Access token required"); @@ -260,6 +262,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke debouncedFilters.sso_user_id || null, debouncedFilters.sort_by, debouncedFilters.sort_order, + orgAdminOrgIds ?? null, ); }, enabled: Boolean(accessToken && token && userRole && userID), @@ -301,17 +304,19 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke ) : userID && accessToken ? ( <> - + - + {isProxyAdmin && ( + + )} - {selectionMode && ( + {isProxyAdmin && selectionMode && ( @@ -321,61 +326,93 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke - setActiveTab(index === 0 ? "users" : "settings")}> - - Users - Default User Settings - + {isProxyAdmin ? ( + setActiveTab(index === 0 ? "users" : "settings")}> + + Users + Default User Settings + - - - { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} - enableSelection={selectionMode} - selectedUsers={selectedUsers} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} - /> - - - - {!userID || !userRole || !accessToken ? ( -
- -
- ) : ( - + + { + setSelectedUser(user); + setEditModalVisible(true); + }} + handleDelete={handleDelete} + handleResetPassword={handleResetPassword} + enableSelection={selectionMode} + selectedUsers={selectedUsers} + onSelectionChange={handleSelectionChange} + filters={filters} + updateFilters={updateFilters} + initialFilters={initialFilters} + teams={teams} + userListResponse={userListResponse} + currentPage={currentPage} + handlePageChange={handlePageChange} /> - )} - -
-
+ + + + {!userID || !userRole || !accessToken ? ( +
+ +
+ ) : ( + + )} +
+ +
+ ) : ( + { + setSelectedUser(user); + setEditModalVisible(true); + }} + handleDelete={handleDelete} + handleResetPassword={handleResetPassword} + enableSelection={false} + selectedUsers={[]} + onSelectionChange={handleSelectionChange} + filters={filters} + updateFilters={updateFilters} + initialFilters={initialFilters} + teams={teams} + userListResponse={userListResponse} + currentPage={currentPage} + handlePageChange={handlePageChange} + /> + )} {/* Existing Modals */} Date: Sat, 7 Mar 2026 17:56:49 -0800 Subject: [PATCH 05/15] fix: improve error messages and extract helper to fix C901 complexity - Extract _resolve_org_filter_for_user_search and _resolve_team_org_filter helpers from ui_view_users to bring complexity under the ruff C901 limit. - Fix misleading error when a team admin searches but team has no org: now says "this team is not part of an organization" instead of the generic "only admins can search" message. - Add distinct errors for team-not-found and not-a-team-admin cases. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 198 +++++++++++------- 1 file changed, 119 insertions(+), 79 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 8d9da86aab2..50693850c56 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1820,6 +1820,118 @@ async def add_internal_user_to_organization( raise Exception(f"Failed to add user to organization: {str(e)}") +async def _resolve_org_filter_for_user_search( + user_api_key_dict: UserAPIKeyAuth, + team_id: Optional[str], + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> Optional[List[str]]: + """ + Return a list of org IDs to filter by, or ``None`` for no filter. + + Reads the ``scope_user_search_to_org`` UI-setting flag and applies + role-based access rules when the flag is ON. + """ + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + ) + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + get_ui_settings_cached, + ) + + ui_settings = await get_ui_settings_cached() + if not ui_settings.get("scope_user_search_to_org", False): + return None # flag OFF — no filtering + + if _user_has_admin_view(user_api_key_dict): + return None # proxy admin — see everything + + # Try to resolve org admin memberships + caller_user = None + if user_api_key_dict.user_id is not None: + try: + caller_user = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except ValueError: + caller_user = None + + org_admin_org_ids: List[str] = [] + if caller_user is not None: + org_admin_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + + if org_admin_org_ids: + return org_admin_org_ids + + if team_id is not None: + return await _resolve_team_org_filter( + user_api_key_dict, team_id, prisma_client, + user_api_key_cache, proxy_logging_obj, + ) + + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + + +async def _resolve_team_org_filter( + user_api_key_dict: UserAPIKeyAuth, + team_id: str, + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> List[str]: + """Look up the team and return its org as a filter list, or raise 403.""" + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + ) + + try: + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + raise HTTPException( + status_code=403, + detail={ + "error": f"scope_user_search_to_org is enabled but team '{team_id}' was not found." + }, + ) + + if not _is_user_team_admin(user_api_key_dict, team_obj): + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. You must be an admin of this team to search users." + }, + ) + + if team_obj.organization_id: + return [team_obj.organization_id] + + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." + }, + ) + + @router.get( "/user/filter/ui", tags=["Internal User management"], @@ -1861,95 +1973,23 @@ async def ui_view_users( - Team admins for an org-bound team see users in that org. - Others receive a 403. """ - from litellm.proxy.management_endpoints.common_utils import ( - _is_user_team_admin, - ) from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, user_api_key_cache, ) - from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( - get_ui_settings_cached, - ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Read the scope_user_search_to_org flag (cached) - ui_settings = await get_ui_settings_cached() - scope_flag = bool(ui_settings.get("scope_user_search_to_org", False)) - - org_filter_ids: Optional[List[str]] = None - - if scope_flag: - is_proxy_admin = _user_has_admin_view(user_api_key_dict) - if not is_proxy_admin: - # Try to resolve org admin memberships - caller_user = None - if user_api_key_dict.user_id is not None: - try: - caller_user = await get_user_object( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - user_id_upsert=False, - proxy_logging_obj=proxy_logging_obj, - ) - except ValueError: - caller_user = None - - org_admin_org_ids: List[str] = [] - if caller_user is not None: - org_admin_org_ids = [ - m.organization_id - for m in (caller_user.organization_memberships or []) - if m.user_role == LitellmUserRoles.ORG_ADMIN.value - ] - - if org_admin_org_ids: - org_filter_ids = org_admin_org_ids - elif team_id is not None: - # Look up the team via cached helper - try: - team_obj = await get_team_object( - team_id=team_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - except HTTPException: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." - }, - ) - if _is_user_team_admin(user_api_key_dict, team_obj): - if team_obj.organization_id: - org_filter_ids = [team_obj.organization_id] - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." - }, - ) - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." - }, - ) - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." - }, - ) + org_filter_ids = await _resolve_org_filter_for_user_search( + user_api_key_dict=user_api_key_dict, + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) # Calculate offset for pagination skip = (page - 1) * page_size From 41d89ab84eeea0c75b96223b3317ebbd8c61dedb Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 18:28:01 -0800 Subject: [PATCH 06/15] fix: address review feedback for org admin user management - Backend authorization: enforce server-side org scoping on /user/list. Non-proxy-admin callers must be org_admin; their permitted org IDs are fetched from the DB and intersected with any requested org filter. - Race condition: gate userListQuery on org data being loaded for non-proxy-admin users (undefined = loading, null = no filter needed). - Silent failure: surface organizationMemberAddCall errors via NotificationsManager instead of only console.error. - Multi-org: org admins managing multiple orgs can choose which org to add the new user to via a dropdown (defaults to first org). - Change organization_id param to List[str] (repeated query params) instead of comma-separated string. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 79 +++++++++++++++++-- .../src/app/(dashboard)/users/page.tsx | 16 ++-- .../src/components/CreateUserButton.tsx | 29 +++++-- .../src/components/networking.tsx | 4 +- .../src/components/view_users.tsx | 4 +- 5 files changed, 110 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1ddb7b47c1d..2e7cf5e7b1e 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1502,10 +1502,11 @@ async def get_users( sort_order: str = fastapi.Query( default="asc", description="Sort order ('asc' or 'desc')" ), - organization_id: Optional[str] = fastapi.Query( + organization_id: Optional[List[str]] = fastapi.Query( default=None, - description="Filter users by organization membership. Comma-separated for multiple orgs.", + description="Filter users by organization membership. Pass multiple values for multiple orgs.", ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Get a paginated list of users with filtering and sorting options. @@ -1534,7 +1535,11 @@ async def get_users( sort_order: Optional[str] Sort order ('asc' or 'desc') """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException( @@ -1542,6 +1547,67 @@ async def get_users( detail={"error": f"No db connected. prisma client={prisma_client}"}, ) + # Server-side authorization: proxy admins see all, org admins see only their org(s) + is_proxy_admin = _user_has_admin_view(user_api_key_dict) + allowed_org_ids: Optional[List[str]] = None + if not is_proxy_admin: + if user_api_key_dict.user_id is None: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can list users." + }, + ) + try: + caller_user = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except ValueError: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can list users." + }, + ) + if caller_user is None: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can list users." + }, + ) + allowed_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + if not allowed_org_ids: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can list users." + }, + ) + # If client also sent organization_id, intersect with allowed orgs + if organization_id: + requested = set(organization_id) + allowed = set(allowed_org_ids) + intersection = list(requested & allowed) + if not intersection: + raise HTTPException( + status_code=403, + detail={ + "error": "You do not have org_admin access to the requested organization(s)." + }, + ) + allowed_org_ids = intersection + # For org admins, always enforce org scoping + organization_id = allowed_org_ids + # Calculate skip and take for pagination skip = (page - 1) * page_size @@ -1580,12 +1646,9 @@ async def get_users( "in": sso_id_list, } - if organization_id is not None and isinstance(organization_id, str): - org_id_list = [ - oid.strip() for oid in organization_id.split(",") if oid.strip() - ] + if organization_id: where_conditions["organization_memberships"] = { - "some": {"organization_id": {"in": org_id_list}} + "some": {"organization_id": {"in": organization_id}} } ## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx index ae7a0b9767a..75a50a0d388 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx @@ -13,14 +13,20 @@ const UsersPage = () => { const [keys, setKeys] = useState([]); const { teams } = useTeams(); - const { data: organizations } = useOrganizations(); + const { data: organizations, isLoading: isOrgsLoading } = useOrganizations(); - // Compute org IDs where the user is an org_admin, but only if they're NOT a proxy admin - const orgAdminOrgIds = useMemo(() => { - if (!userId || !organizations || !userRole) return null; + // Three states: + // - undefined: org data still loading (non-proxy-admin) — query should wait + // - null: proxy admin or no org filtering needed — query runs unfiltered + // - string[]: org admin org IDs — query runs filtered + const orgAdminOrgIds = useMemo((): string[] | null | undefined => { + if (!userId || !userRole) return null; // Proxy admins see all users — no org filtering if (isProxyAdminRole(userRole)) return null; + // Still loading org data — signal "not ready yet" + if (isOrgsLoading || !organizations) return undefined; + const adminOrgIds = organizations .filter((org: Organization) => org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin") @@ -28,7 +34,7 @@ const UsersPage = () => { .map((org: Organization) => org.organization_id); return adminOrgIds.length > 0 ? adminOrgIds : null; - }, [userId, organizations, userRole]); + }, [userId, organizations, userRole, isOrgsLoading]); return ( >; onUserCreated?: (userId: string) => void; isEmbedded?: boolean; - organizationId?: string | null; + organizationIds?: string[] | null; } // Define an interface for the UI settings @@ -57,7 +57,7 @@ interface UISettings { } export const CreateUserButton: React.FC = ({ - userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationId }) => { + userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationIds }) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -100,7 +100,7 @@ export const CreateUserButton: React.FC = ({ form.resetFields(); }; - const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string }) => { + const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string; organization_id?: string }) => { try { NotificationsManager.info("Making API Call"); if (!isEmbedded) { @@ -114,15 +114,17 @@ export const CreateUserButton: React.FC = ({ setApiuser(true); const user_id = response.data?.user_id || response.user_id; - // Auto-add user to the org admin's organization - if (organizationId && user_id) { + // Auto-add user to the selected organization (org admin flow) + const targetOrgId = formValues.organization_id || (organizationIds?.length === 1 ? organizationIds[0] : null); + if (targetOrgId && user_id) { try { - await organizationMemberAddCall(accessToken, organizationId, { + await organizationMemberAddCall(accessToken, targetOrgId, { role: "internal_user", user_id: user_id, }); } catch (orgError) { console.error("Failed to add user to organization:", orgError); + NotificationsManager.fromBackend("User created but failed to add to organization. Please add them manually."); } } @@ -293,6 +295,21 @@ export const CreateUserButton: React.FC = ({ + {organizationIds && organizationIds.length > 1 && ( + + + + )} + diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 1dd9afc698e..8c5e1157218 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1153,7 +1153,9 @@ export const userListCall = async ( } if (organizationIds && organizationIds.length > 0) { - queryParams.append("organization_id", organizationIds.join(",")); + for (const orgId of organizationIds) { + queryParams.append("organization_id", orgId); + } } const queryString = queryParams.toString(); diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 49a55f393ff..2d3dde2f0fd 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -265,7 +265,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke orgAdminOrgIds ?? null, ); }, - enabled: Boolean(accessToken && token && userRole && userID), + enabled: Boolean(accessToken && token && userRole && userID && orgAdminOrgIds !== undefined), placeholderData: (previousData) => previousData, }); const userListResponse = userListQuery.data; @@ -304,7 +304,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke ) : userID && accessToken ? ( <> - + {isProxyAdmin && ( + @@ -359,4 +365,4 @@ export const CreateUserButton: React.FC = ({ )} ); -}; \ No newline at end of file +}; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 1e9f724d751..fb149458f61 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganization: vi.fn(), + useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }), })); vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 7d350b281e4..d2ce79580da 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1,4 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import UserSearchModal from "@/components/common_components/user_search_modal"; import { getGuardrailsList, @@ -122,6 +123,7 @@ export interface TeamInfoProps { accessToken: string | null; is_team_admin: boolean; is_proxy_admin: boolean; + is_org_admin?: boolean; userModels: string[]; editTeam: boolean; premiumUser?: boolean; @@ -156,6 +158,7 @@ const TeamInfoView: React.FC = ({ accessToken, is_team_admin, is_proxy_admin, + is_org_admin = false, userModels, editTeam, premiumUser = false, @@ -180,9 +183,18 @@ const TeamInfoView: React.FC = ({ const [isDeleting, setIsDeleting] = useState(false); const [isTeamSaving, setIsTeamSaving] = useState(false); const [organization, setOrganization] = useState(null); - const { userRole } = useAuthorized(); + const { userRole, userId } = useAuthorized(); + const { data: userOrganizations = [] } = useOrganizations(); - const canEditTeam = is_team_admin || is_proxy_admin; + // Check if user is org admin for this team's organization + const isOrgAdminForTeam = useMemo(() => { + const teamOrgId = teamData?.team_info?.organization_id; + if (!teamOrgId || !userId) return false; + const org = userOrganizations.find((o) => o.organization_id === teamOrgId); + return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false; + }, [teamData, userOrganizations, userId]); + + const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam; const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo( () => getTeamInfoDefaultTab(editTeam, canEditTeam), diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index c72320d08a7..652d1dbcd91 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -148,7 +148,7 @@ export default function TeamMemberTab({ roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." extraColumns={extraColumns} showDeleteForMember={() => - isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) + isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) } /> ); diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 18c5e042e1e..f4c821fb01e 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -265,7 +265,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, ); }, - enabled: Boolean(accessToken && token && userRole && userID && orgAdminOrgIds !== undefined), + enabled: Boolean(accessToken && token && userRole && userID), placeholderData: (previousData) => previousData, }); const userListResponse = userListQuery.data; @@ -304,7 +304,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke ) : userID && accessToken ? ( <> - + {isProxyAdmin && (