From 22e8bf1969fc6c78f4737b1d9eb0906b3abe367a Mon Sep 17 00:00:00 2001 From: Tin Date: Tue, 27 Oct 2026 16:25:28 -0700 Subject: [PATCH] fix(auth)!: scope user search while preserving team enrollment --- .../internal_user_endpoints.py | 51 ++--- .../organization_endpoints.py | 21 +- .../proxy_setting_endpoints.py | 4 +- .../test_internal_user_endpoints.py | 179 +++++++++++------- .../test_organization_endpoints.py | 67 +++++++ .../AdminSettings/UISettings/UISettings.tsx | 2 +- .../user_search_modal.integration.test.tsx | 83 +++++++- .../common_components/user_search_modal.tsx | 33 ++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 9 +- 9 files changed, 324 insertions(+), 125 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index d6bf9220551..5305fbe09e4 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -55,7 +55,8 @@ from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks from litellm.proxy.hooks.model_max_budget_limiter import build_model_max_budget_usage from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks -from litellm.proxy.management.teams.authz import is_team_admin +from litellm.proxy.management.teams.authz import TEAM_OR_ORG_ADMIN, is_team_admin +from litellm.proxy.management.teams.dependencies import get_team_access from litellm.proxy.management_endpoints.common_daily_activity import ( DailySpendRecord, ScopeDenied, @@ -2657,12 +2658,21 @@ async def _resolve_org_filter_for_user_search( ) ui_settings: Final = await get_ui_settings_cached() - if not ui_settings.get("scope_user_search_to_org", False): + if ui_settings.get("scope_user_search_to_org") is False: return None # flag OFF — no filtering if user_api_key_has_admin_view(user_api_key_dict): return None # proxy admin — see everything + 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, + ) + # Try to resolve org admin memberships caller_user = None if user_api_key_dict.user_id is not None: @@ -2686,12 +2696,10 @@ async def _resolve_org_filter_for_user_search( if member_org_ids: return member_org_ids - # Fall back to resolving via team_id (query param or from the caller's API key) - resolved_team_id: Final = team_id or user_api_key_dict.team_id - if resolved_team_id is not None: + if user_api_key_dict.team_id is not None: return await _resolve_team_org_filter( user_api_key_dict, - resolved_team_id, + user_api_key_dict.team_id, prisma_client, user_api_key_cache, proxy_logging_obj, @@ -2700,7 +2708,7 @@ async def _resolve_org_filter_for_user_search( 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. Only proxy admins, organization members, or team admins can search users." }, ) @@ -2711,14 +2719,14 @@ async def _resolve_team_org_filter( prisma_client: "PrismaClient | None", user_api_key_cache: "UserApiKeyCache", proxy_logging_obj: "ProxyLogging | None", -) -> list[str]: - """Look up the team and return its org as a filter list, or raise 403.""" +) -> list[str] | None: try: team_obj: Final = 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, + check_db_only=True, ) except HTTPException: raise HTTPException( @@ -2726,21 +2734,15 @@ async def _resolve_team_org_filter( detail={"error": f"scope_user_search_to_org is enabled but team '{team_id}' was not found."}, ) - if not is_team_admin(user_api_key_dict, team_obj): + if not await get_team_access().allows(user_api_key_dict, team_obj, TEAM_OR_ORG_ADMIN): 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."}, + detail={ + "error": "scope_user_search_to_org is enabled. You must manage this team to search its candidates." + }, ) - 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." - }, - ) + return [team_obj.organization_id] if team_obj.organization_id else None @router.get( @@ -2773,11 +2775,12 @@ async def ui_view_users( Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag (stored in the ``litellm_uisettings`` table): - * **Flag OFF (default):** any authenticated user can search all users. - * **Flag ON:** + * **Flag OFF:** any authenticated user can search all users. + * **Flag ON (default):** - 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. + - A team or organization admin searching for a selected team's members + sees users in that team's org, or all users if the team has no org. + - Without a selected team, org members see users in their org(s). - Others receive a 403. """ from litellm.proxy.proxy_server import ( diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index e1d19d8efae..a36918095bf 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -1662,13 +1662,20 @@ async def add_member_to_organization( if existing_user_id_row is None and member.user_email is not None: try: - existing_user_email_row = await UserRepository(prisma_client).table.find_unique( - where={"user_email": member.user_email} + matching_users: Final = await UserRepository(prisma_client).table.find_many( + where={"user_email": {"equals": member.user_email, "mode": "insensitive"}}, + take=2, ) except Exception as e: raise ValueError( f"Potential NON-Existent or Duplicate user email in DB: Error finding a unique instance of user_email={member.user_email} in LiteLLM_UserTable.: {e}" ) + if len(matching_users) > 1: + raise HTTPException( + status_code=400, + detail={"error": "Multiple users with this email found in db. Please use 'user_id' instead."}, + ) + existing_user_email_row = next(iter(matching_users), None) ## If user does not exist, create a new user if existing_user_id_row is None and existing_user_email_row is None: @@ -1682,14 +1689,6 @@ async def add_member_to_organization( _returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user") if _returned_user is not None: user_object = LiteLLM_UserTable.model_validate(_returned_user.model_dump()) - elif existing_user_email_row is not None and ( - len(existing_user_email_row) # pyright: ignore[reportArgumentType] # find_unique yields a row, not a list - > 1 - ): - raise HTTPException( - status_code=400, - detail={"error": "Multiple users with this email found in db. Please use 'user_id' instead."}, - ) elif existing_user_email_row is not None: user_object = LiteLLM_UserTable.model_validate(existing_user_email_row.model_dump()) elif existing_user_id_row is not None: @@ -1718,5 +1717,7 @@ async def add_member_to_organization( ) return user_object, organization_membership + except HTTPException: + raise except Exception as e: raise ValueError(f"Error adding member={member} to organization={organization_id}: {e}") diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 0efd4e4c619..589d2c47c08 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -337,8 +337,8 @@ class UISettings(LiteLLMBaseModel): ) 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.", + default=True, + description="Enabled by default. User search (/user/filter/ui) is restricted by organization. Proxy admins and verified admins searching for members of teams without organizations can search all users. Disabling this allows any authenticated user to search all users.", ) disable_custom_api_keys: bool = Field( diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index cb8a73d581b..a803522b21e 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -299,7 +299,7 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): @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. + Flag explicitly OFF: authenticated users can search without org filtering. """ mock_prisma_client = mocker.MagicMock() @@ -313,7 +313,7 @@ async def test_ui_view_users_flag_off_internal_user_can_search(mocker): # Flag OFF mocker.patch( "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", - return_value={}, + return_value={"scope_user_search_to_org": False}, ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -329,6 +329,117 @@ async def test_ui_view_users_flag_off_internal_user_can_search(mocker): assert response == [] +@pytest.mark.asyncio +@pytest.mark.parametrize("setting", [None, True, False], ids=["missing", "scoped", "opt-out"]) +@pytest.mark.parametrize( + ("principal", "expected_scope"), + [ + ("org-member", "org-a"), ("no-org", "denied"), ("admin", "all"), ("admin-viewer", "all"), + ("team-query", "org-b"), ("team-key", "org-b"), ("team-no-org", "all"), ("team-no-org-key", "all"), + ("member-team-query", "org-b"), ("member-team-no-org", "all"), ("member-team-key", "org-a"), + ("org-admin-team", "org-a"), ("org-member-target", "denied"), ("team-member", "denied"), + ("forged-team", "denied"), ("missing-team", "denied"), ("missing-user", "denied"), + ("revoked-admin", "denied"), ("changed-org", "org-b"), + ], +) +async def test_ui_view_users_scope_defaults_and_explicit_opt_out( + monkeypatch: pytest.MonkeyPatch, setting: bool | None, principal: str, expected_scope: str, +) -> None: + from litellm.proxy import proxy_server + from litellm.proxy._types import LiteLLM_OrganizationMembershipTable, LiteLLM_TeamTableCachedObj + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UI_SETTINGS_CACHE_KEY + + cache: Final = UserApiKeyCache() + await cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, + value={} if setting is None else {"scope_user_search_to_org": setting}, + ) + membership: Final = LiteLLM_OrganizationMembershipTable( + user_id="search-caller", organization_id="org-a", + user_role="org_admin" if principal == "org-admin-team" else "internal_user", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + caller_has_org: Final = principal.startswith("member-") or principal in { + "org-member", "org-admin-team", "org-member-target", + } + caller: Final = LiteLLM_UserTable( + user_id="search-caller", user_email="caller@example.test", + organization_memberships=[membership] if caller_has_org else [], + ) + await cache.async_set_cache(key=caller.user_id, value=caller, model_type=LiteLLM_UserTable) + target_org: Final = "org-a" if principal in {"org-admin-team", "org-member-target"} else "org-b" + current_team: Final = LiteLLM_TeamTableCachedObj( + team_id="search-team", organization_id=None if "no-org" in principal else target_org, + members_with_roles=[{ + "user_id": "other-admin" if principal in {"forged-team", "org-admin-team"} else caller.user_id, + "role": "user" if principal in { + "team-member", "org-member-target", "revoked-admin", "member-team-key", + } else "admin", + }], + ) + cached_team: Final = LiteLLM_TeamTableCachedObj.model_validate({ + **current_team.model_dump(), + "organization_id": "org-a" if principal == "changed-org" else current_team.organization_id, + "members_with_roles": [{"user_id": caller.user_id, "role": "admin"}], + }) if principal in {"changed-org", "revoked-admin", "missing-team"} else current_team + await cache.async_set_cache( + key=f"team_id:{current_team.team_id}", value=cached_team, model_type=LiteLLM_TeamTableCachedObj, + ) + directory: Final = { + "org-a": LiteLLM_UserTableFiltered(user_id="member-a", user_email="a@example.test"), + "org-b": LiteLLM_UserTableFiltered(user_id="member-b", user_email="b@example.test"), + "unassigned": LiteLLM_UserTableFiltered(user_id="unassigned", user_email="unassigned@example.test"), + } + + async def find_many(*, where: Mapping[str, object], **_: object) -> list[LiteLLM_UserTableFiltered]: + scope: Final = where.get("organization_memberships") + return [ + row for organization_id, row in directory.items() + if scope is None or scope == {"some": {"organization_id": {"in": [organization_id]}}} + ] + + async def find_team(*, where: Mapping[str, object], **_: object) -> LiteLLM_TeamTableCachedObj | None: + return current_team if where == {"team_id": current_team.team_id} and principal != "missing-team" else None + + read_users: Final = AsyncMock(side_effect=find_many) + database: Final = SimpleNamespace(litellm_usertable=SimpleNamespace(find_many=read_users)) + writer: Final = SimpleNamespace(litellm_teamtable=SimpleNamespace(find_unique=find_team)) + monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=database, writer_db=writer)) + monkeypatch.setattr(proxy_server, "user_api_key_cache", cache) + monkeypatch.setattr(proxy_server, "proxy_logging_obj", None) + role: Final = { + "admin": LitellmUserRoles.PROXY_ADMIN, + "admin-viewer": LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + }.get(principal, LitellmUserRoles.INTERNAL_USER) + key_team: Final = current_team.team_id if principal.endswith("-key") else None + actor: Final = UserAPIKeyAuth( + user_id=None if principal == "missing-user" else caller.user_id, user_role=role, + team_id="other-team" if principal == "forged-team" else key_team, + ) + query_team: Final = ( + None if principal in {"org-member", "no-org", "admin", "admin-viewer"} or key_team else current_team.team_id + ) + if setting is not False and expected_scope == "denied": + with pytest.raises(HTTPException) as raised: + await ui_view_users( + user_api_key_dict=actor, user_id=None, user_email=None, search=None, + team_id=query_team, page=1, page_size=50, + ) + assert raised.value.status_code == 403 + read_users.assert_not_awaited() + return + + result: Final = await ui_view_users( + user_api_key_dict=actor, user_id=None, user_email=None, search=None, + team_id=query_team, page=1, page_size=50, + ) + expected: Final = ( + list(directory.values()) if setting is False or expected_scope == "all" else [directory[expected_scope]] + ) + assert result == expected + + @pytest.mark.asyncio async def test_ui_view_users_flag_on_team_admin_org_team(mocker): """ @@ -398,70 +509,6 @@ async def test_ui_view_users_flag_on_team_admin_org_team(mocker): 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 - - from litellm.proxy._types import LiteLLM_TeamTableCachedObj - - mock_prisma_client = mocker.MagicMock() - tid = "team-no-org" - - # Flag ON - mocker.patch( - "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", - return_value={"scope_user_search_to_org": True}, - ) - - # 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"}], - ) - - async def mock_get_team_object(*args, **kwargs): - return team_obj - - 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()) - 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_team_admin_org_member_no_team_id(mocker): """ diff --git a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py index 16c59aa20b9..d668387fdb6 100644 --- a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py @@ -682,6 +682,73 @@ async def test_organization_member_add_budget_omission_and_null_leave_budget_uns assert response.updated_organization_memberships[0].budget_id is None +@pytest.mark.asyncio +@pytest.mark.parametrize( + "existing_org_id, match_count", + [(None, 1), ("other-org", 1), (None, 2)], + ids=["unassigned", "other-org", "duplicate-email"], +) +async def test_organization_member_add_by_email_preserves_existing_user( + monkeypatch: pytest.MonkeyPatch, existing_org_id: str | None, match_count: int, +) -> None: + from datetime import datetime, timezone + + from fastapi import Request + from prisma.models import LiteLLM_OrganizationMembership, LiteLLM_UserTable + + from litellm.proxy import proxy_server + from litellm.proxy._types import LitellmUserRoles, OrganizationMemberAddRequest, ProxyException, UserAPIKeyAuth + from litellm.proxy.management_endpoints.organization_endpoints import organization_member_add + + timestamp: Final = datetime(2026, 1, 1, tzinfo=timezone.utc) + existing_memberships: Final = [] if existing_org_id is None else [LiteLLM_OrganizationMembership( + user_id="existing-user", organization_id=existing_org_id, user_role="internal_user", spend=0, + created_at=timestamp, updated_at=timestamp, + )] + user: Final = LiteLLM_UserTable.model_validate({ + "user_id": "existing-user", "user_email": "existing@example.test", "user_role": "internal_user", + "teams": [], "spend": 0, "models": [], "metadata": "{}", "allowed_cache_controls": [], "policies": [], + "model_spend": "{}", "model_max_budget": "{}", "organization_memberships": existing_memberships, + }) + membership: Final = LiteLLM_OrganizationMembership( + user_id=user.user_id, organization_id="target-org", user_role="internal_user", spend=0, + created_at=timestamp, updated_at=timestamp, + ) + matches: Final = [user] if match_count == 1 else [user, user.model_copy(update={"user_id": "duplicate-user"})] + find_user: Final = AsyncMock(return_value=matches) + create_membership: Final = AsyncMock(return_value=membership) + insert_user: Final = AsyncMock() + database: Final = SimpleNamespace( + litellm_organizationtable=SimpleNamespace(find_unique=AsyncMock(return_value=SimpleNamespace())), + litellm_usertable=SimpleNamespace(find_many=find_user), + litellm_organizationmembership=SimpleNamespace(create=create_membership), + ) + monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=database, insert_data=insert_user)) + + request_data: Final = OrganizationMemberAddRequest( + organization_id=membership.organization_id, + member={"role": "internal_user", "user_email": "EXISTING@example.test"}, + ) + request: Final = Request({"type": "http", "method": "POST", "path": "/organization/member_add", "headers": []}) + caller: Final = UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN) + if match_count > 1: + with pytest.raises(ProxyException) as exc: + await organization_member_add(data=request_data, http_request=request, user_api_key_dict=caller) + assert int(exc.value.code) == 400 + create_membership.assert_not_awaited() + insert_user.assert_not_awaited() + return + + response: Final = await organization_member_add(data=request_data, http_request=request, user_api_key_dict=caller) + + assert [row.user_id for row in response.updated_users] == [user.user_id] + assert response.updated_organization_memberships[0].organization_id == membership.organization_id + create_membership.assert_awaited_once_with(data={ + "organization_id": membership.organization_id, "user_id": user.user_id, "user_role": "internal_user", + }) + insert_user.assert_not_awaited() + + @pytest.mark.asyncio @pytest.mark.parametrize( "budget_payload", 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 3103851f280..5a1f1042265 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -504,7 +504,7 @@ export default function UISettings() { label="Scope user search to organization" description={ scopeUserSearchProperty?.description ?? - "If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users." + "Enabled by default. User search is restricted by organization. Proxy admins and verified admins searching for members of teams without organizations can search all users. Disabling this allows any authenticated user to search all users." } /> diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx index 5769d8c2f88..d97f2219ce1 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.integration.test.tsx @@ -68,12 +68,12 @@ describe("UserSearchModal", () => { expect(userFilterUICall).not.toHaveBeenCalled(); }); - it("tells the user that only existing accounts can be selected", () => { + it("explains scoped search and exact-email enrollment", () => { renderModal(); const notice = screen.getByRole("alert"); - expect(notice).toHaveTextContent(/users that already exist/i); - expect(notice).toHaveTextContent(/ask a proxy admin to create their account first/i); + expect(notice).toHaveTextContent(/search for users you can access/i); + expect(notice).toHaveTextContent(/enter a complete email address/i); // info, not warning: a warning here would read as an error state on an empty form expect(notice).toHaveAttribute("data-variant", "info"); }); @@ -116,6 +116,59 @@ describe("UserSearchModal submit payload", () => { expect(onSubmit).not.toHaveBeenCalled(); }); + it.each(["empty", "forbidden"])("submits an exact email when directory search is %s", async (result) => { + vi.mocked(userFilterUICall).mockReset(); + if (result === "forbidden") { + vi.mocked(userFilterUICall).mockRejectedValue(new Error("403 Forbidden")); + } else { + vi.mocked(userFilterUICall).mockResolvedValue([]); + } + const { user, onSubmit } = setup(); + const input = getEmailSearchInput(); + await user.click(input); + await user.type(input, "invite@example.com"); + await user.click(await screen.findByRole("option", { name: "Use email invite@example.com" })); + await user.click(save()); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + user_email: "invite@example.com", + user_id: null, + role: "user", + }), + ); + }); + + it("clears the previous user ID when selecting an email outside search results", async () => { + const { user, onSubmit } = setup(); + await searchByEmail(user, "pick"); + vi.mocked(userFilterUICall).mockResolvedValue([]); + await user.click(getEmailSearchInput()); + await user.type(getEmailSearchInput(), "invite@example.com"); + await user.click(await screen.findByRole("option", { name: "Use email invite@example.com" })); + await user.click(save()); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + user_email: "invite@example.com", + user_id: null, + role: "user", + }), + ); + }); + + it.each(["partial", "invalid@"])("does not offer an invitation for %s", async (email) => { + vi.mocked(userFilterUICall).mockResolvedValue([]); + const { user } = setup(); + await user.click(getEmailSearchInput()); + await user.type(getEmailSearchInput(), email); + await screen.findByText("No results"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(save()).toBeDisabled(); + }); + it("carries the picked user's email and id into the payload", async () => { const { user, onSubmit } = setup(); @@ -130,6 +183,24 @@ describe("UserSearchModal submit payload", () => { }); }); + it("preserves a missing email when selecting an existing user by ID", async () => { + vi.mocked(userFilterUICall).mockResolvedValue([{ user_id: "id-only-user", user_email: null }]); + const { user, onSubmit } = setup(); + const input = screen.getByLabelText("User ID"); + await user.click(input); + await user.type(input, "id-only"); + await user.click(await screen.findByRole("option", { name: "id-only-user" })); + await user.click(save()); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + user_email: null, + user_id: "id-only-user", + role: "user", + }), + ); + }); + it("carries a role changed off its default into the payload", async () => { const { onSubmit } = setup(); const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); @@ -333,15 +404,15 @@ describe("UserSearchModal out-of-order search results", () => { await user.type(input, "ali"); await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 }); - await user.type(input, "ce.smith@example.com"); - await waitFor(() => expect(answers.has("alice.smith@example.com")).toBe(true), { timeout: 3000 }); + await user.type(input, "ce.smith"); + await waitFor(() => expect(answers.has("alice.smith")).toBe(true), { timeout: 3000 }); await answerFor("ali", []); expect(screen.getByText("Loading...")).toBeInTheDocument(); expect(screen.queryByText("No results")).not.toBeInTheDocument(); - await answerFor("alice.smith@example.com", [{ user_id: "u-smith", user_email: "alice.smith@example.com" }]); + await answerFor("alice.smith", [{ user_id: "u-smith", user_email: "alice.smith@example.com" }]); await screen.findByRole("option", { name: "alice.smith@example.com" }); }); }); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 04ac412397f..e04368022ce 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { z } from "zod"; import { Info, UserPlus } from "lucide-react"; import { Alert, AlertTitle } from "@/components/shared/Alert"; import { useForm } from "react-hook-form"; @@ -14,7 +15,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface User { user_id: string; - user_email: string; + user_email: string | null; role?: string; } @@ -85,6 +86,12 @@ const UserSearchModal: React.FC = ({ return; } + const email = searchText.trim(); + const emailOptions: UserOption[] = + fieldName === "user_email" && z.email({ pattern: z.regexes.html5Email }).safeParse(email).success + ? [{ label: `Use email ${email}`, value: email, user: null }] + : []; + setUserOptions(emailOptions); setLoading(true); try { const params = new URLSearchParams(); @@ -99,12 +106,14 @@ const UserSearchModal: React.FC = ({ if (!isLatestSearch()) return; const data: User[] = response; - const options: UserOption[] = data.map((user) => ({ - label: fieldName === "user_email" ? `${user.user_email}` : `${user.user_id}`, - value: fieldName === "user_email" ? user.user_email : user.user_id, - user, - })); - setUserOptions(options); + const options: UserOption[] = data.map((user) => { + const value = fieldName === "user_email" ? user.user_email ?? user.user_id : user.user_id; + return { label: value, value, user }; + }); + setUserOptions([ + ...options, + ...emailOptions.filter((option) => !options.some((user) => user.value === option.value)), + ]); } catch (error) { console.error("Error fetching users:", error); } finally { @@ -118,9 +127,9 @@ const UserSearchModal: React.FC = ({ }; const handleSelect = (option: UserOption | null): void => { - if (option?.user == null) return; - form.setValue("user_email", option.user.user_email); - form.setValue("user_id", option.user.user_id); + if (option == null) return; + form.setValue("user_email", option.user === null ? option.value : option.user.user_email); + form.setValue("user_id", option.user?.user_id ?? null); }; const handleSubmit = async (values: FormValues): Promise => { @@ -191,8 +200,8 @@ const UserSearchModal: React.FC = ({ - Search selects from users that already exist. To add someone new, ask a proxy admin to create their - account first. + Search for users you can access, or enter a complete email address to add a member without searching the + directory. diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 13a8a8b993d..6384dc701ca 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -18648,11 +18648,12 @@ export interface paths { * Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag * (stored in the ``litellm_uisettings`` table): * - * * **Flag OFF (default):** any authenticated user can search all users. - * * **Flag ON:** + * * **Flag OFF:** any authenticated user can search all users. + * * **Flag ON (default):** * - 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. + * - A team or organization admin searching for a selected team's members + * sees users in that team's org, or all users if the team has no org. + * - Without a selected team, org members see users in their org(s). * - Others receive a 403. */ get: operations["ui_view_users_user_filter_ui_get"];