From da82ea8e94b737b7f5873609edf1e842699e5aa2 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:30:29 -0700 Subject: [PATCH] fix(ui): let the Create Key user picker find users by user_id, not just email (#41687) * feat(ui): search users by id or email when assigning a key owner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): label users without an email by user id in key owner picker Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): freeze merged user-filter where, format create key test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): suppress module-global patch findings in ui_view_users search test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): keep merged user-filter where as a plain dict for prisma serialization Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): forward search param from userFilterUICall to /user/filter/ui Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): mention user ID in the Create Key user picker helper text Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: jesus Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal_user_endpoints.py | 14 +++- .../test_internal_user_endpoints.py | 66 +++++++++++++++++++ .../src/components/networking.test.ts | 23 +++++++ .../src/components/networking.tsx | 1 + .../create_key_button.integration.test.tsx | 53 ++++++++++----- .../organisms/create_key_button.tsx | 10 +-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +- 7 files changed, 148 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 133181e9203..587ae416096 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2741,6 +2741,10 @@ async def _resolve_team_org_filter( async def ui_view_users( user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"), user_email: str | None = fastapi.Query(default=None, description="User email in the request parameters"), + search: str | None = fastapi.Query( + default=None, + description="Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive).", + ), team_id: str | None = fastapi.Query( default=None, description="Team ID — used when a team admin searches for users to add to their team", @@ -2750,7 +2754,7 @@ async def ui_view_users( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Filter users based on partial match of user_id or email with pagination. + Filter users based on partial match of user_id or email, or combined ``search``, with pagination. Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag (stored in the ``litellm_uisettings`` table): @@ -2802,9 +2806,15 @@ async def ui_view_users( if org_filter_ids is not None: where_conditions["organization_memberships"] = {"some": {"organization_id": {"in": org_filter_ids}}} + where: Final[Mapping[str, object]] = { # mutable-ok: prisma serializes `where`, keep it a plain dict + key: value + for key, value in (*where_conditions.items(), *_user_search_where(search).items()) + if value is not None + } + # Query users with pagination and filters users: Final = await _user_table(prisma_client).find_many( - where=where_conditions, + where=where, skip=skip, take=page_size, order={"created_at": "desc"}, 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 2d1049b143e..0465572235b 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 @@ -2,6 +2,7 @@ import asyncio import hashlib import json import logging +from collections.abc import Mapping, Sequence from datetime import datetime, timezone from types import SimpleNamespace from typing import Final @@ -37,6 +38,7 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import ( ui_view_users, ) from litellm.proxy.proxy_server import app +from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains from tests.test_litellm.proxy.management_endpoints.jwt_key_mapping_doubles import ( CascadingJWTMappingTable, JWTMappingRow, @@ -119,6 +121,70 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): ) +UserWhereCondition = InsensitiveContains | Sequence[Mapping[str, InsensitiveContains]] + + +def _matches_user_where(row: LiteLLM_UserTableFiltered, where: Mapping[str, UserWhereCondition]) -> bool: + def matches(field: str, condition: UserWhereCondition) -> bool: + if not isinstance(condition, Mapping): + return any(_matches_user_where(row, branch) for branch in condition) + value: Final = {"user_id": row.user_id, "user_email": row.user_email}[field] + return value is not None and condition["contains"].lower() in value.lower() + + return all(matches(field, condition) for field, condition in where.items()) + + +@pytest.mark.parametrize( + "params, expected_user_ids", + [ + ({"search": "SVC"}, ["svc-bot"]), + ({"search": "ali"}, ["alice-admin"]), + ({"search": "example.com"}, ["alice-admin"]), + ({"search": "admin"}, ["alice-admin"]), + ({"user_email": "svc"}, []), + ({"user_id": "svc"}, ["svc-bot"]), + ({"search": "ali", "user_id": "svc"}, []), + ], +) +def test_ui_view_users_search_matches_user_id_or_email( + mocker: MockerFixture, params: Mapping[str, str], expected_user_ids: list[str] +): + """ + search= returns users whose user_id or user_email contains the value (case-insensitive), + including users with no email; user_id=/user_email= keep filtering a single field and AND with search. + """ + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + users = ( + LiteLLM_UserTableFiltered(user_id="alice-admin", user_email="alice@example.com"), + LiteLLM_UserTableFiltered(user_id="svc-bot", user_email=None), + LiteLLM_UserTableFiltered(user_id="bob", user_email="bob@corp.io"), + ) + + async def mock_find_many(*, where: Mapping[str, UserWhereCondition], **_: object): + return [user for user in users if _matches_user_where(user, where)] + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mocker.patch( # test-quality-ok: endpoint reads settings via module global; same seam as sibling tests + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) + mocker.patch( # test-quality-ok: endpoint reads prisma_client via module global; same seam as sibling tests + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get("/user/filter/ui", params=params) + finally: + app.dependency_overrides.pop(user_api_key_auth, None) + + assert response.status_code == 200, response.text + assert [user["user_id"] for user in response.json()] == expected_user_ids + + @pytest.mark.asyncio async def test_ui_view_users_org_admin_filtered_by_org(mocker): """ diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 3b2a17101ee..e14f1939ee1 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -913,3 +913,26 @@ describe("fetchMemoryList search serialization", () => { expect(lastParams(mockFetch).has("search")).toBe(false); }); }); + +describe("userFilterUICall", () => { + let currentFetch: typeof global.fetch; + + beforeEach(() => { + currentFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = currentFetch; + }); + + it("forwards the search param to /user/filter/ui", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, text: async () => "[]" } as any); + global.fetch = mockFetch as any; + + await Networking.userFilterUICall("sk-test", new URLSearchParams({ search: "svc" })); + + const parsed = new URL(mockFetch.mock.calls[0][0] as string, "http://localhost"); + expect(parsed.pathname).toContain("/user/filter/ui"); + expect(parsed.searchParams.get("search")).toBe("svc"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d358d23408c..76c6a3cb935 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1983,6 +1983,7 @@ export const userFilterUICall = async (accessToken: string, params: URLSearchPar user_email: params.get("user_email") || undefined, user_id: params.get("user_id") || undefined, team_id: params.get("team_id") || undefined, + search: params.get("search") || undefined, }, }); } catch (error) { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index a6b37ac26ff..0bf3268379c 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -204,7 +204,8 @@ const openModal = async (props: Partial> return view; }; -const userSearchInput = (): Promise => screen.findByPlaceholderText("Type email to search for users"); +const userSearchInput = (): Promise => + screen.findByPlaceholderText("Type email or user ID to search for users"); const openSection = async (name: RegExp) => { await userEvent.click(await screen.findByRole("button", { name })); @@ -619,7 +620,7 @@ describe("CreateKey", () => { it("mounts the user search control only once Another User is chosen", async () => { await openModal(); - expect(screen.queryByPlaceholderText("Type email to search for users")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Type email or user ID to search for users")).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("radio", { name: "Another User" })); @@ -946,18 +947,18 @@ describe("CreateKey", () => { expect(vi.mocked(userFilterUICall)).toHaveBeenCalledTimes(1); const params = vi.mocked(userFilterUICall).mock.calls[0][1] as URLSearchParams; - expect(params.get("user_email")).toBe("alice"); + expect(params.get("search")).toBe("alice"); } finally { vi.useRealTimers(); } }); it("keeps the current search's users when an abandoned search answers last", async () => { - const answers = new Map void>(); + const answers = new Map void>(); vi.mocked(userFilterUICall).mockImplementation( (_accessToken, params) => new Promise((resolve) => { - answers.set(params.get("user_email") ?? "", resolve); + answers.set(params.get("search") ?? "", resolve); }) as never, ); @@ -984,12 +985,36 @@ describe("CreateKey", () => { expect(screen.getByRole("option", { name: "alice.smith@example.com (u-smith)" })).toBeInTheDocument(); }); - it("stops searching once the box is cleared and the abandoned search answers", async () => { - const answers = new Map void>(); + it("labels a user with no email by their user id", async () => { + const answers = new Map void>(); vi.mocked(userFilterUICall).mockImplementation( (_accessToken, params) => new Promise((resolve) => { - answers.set(params.get("user_email") ?? "", resolve); + answers.set(params.get("search") ?? "", resolve); + }) as never, + ); + + const user = userEvent.setup(); + renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } }); + const search = await userSearchInput(); + + await user.type(search, "svc"); + await waitFor(() => expect(answers.has("svc")).toBe(true), { timeout: 3000 }); + + await act(async () => { + answers.get("svc")?.([{ user_id: "svc-bot", user_email: null }]); + }); + + expect(await screen.findByRole("option", { name: "svc-bot" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /null/ })).not.toBeInTheDocument(); + }); + + it("stops searching once the box is cleared and the abandoned search answers", async () => { + const answers = new Map void>(); + vi.mocked(userFilterUICall).mockImplementation( + (_accessToken, params) => + new Promise((resolve) => { + answers.set(params.get("search") ?? "", resolve); }) as never, ); @@ -1013,11 +1038,11 @@ describe("CreateKey", () => { }); it("keeps searching while a newer search is still in flight", async () => { - const answers = new Map void>(); + const answers = new Map void>(); vi.mocked(userFilterUICall).mockImplementation( (_accessToken, params) => new Promise((resolve) => { - answers.set(params.get("user_email") ?? "", resolve); + answers.set(params.get("search") ?? "", resolve); }) as never, ); @@ -1047,12 +1072,12 @@ describe("CreateKey", () => { it("only warns about a failed search when it is the one the box is waiting on", async () => { const answers = new Map< string, - { resolve: (users: { user_id: string; user_email: string }[]) => void; reject: (error: Error) => void } + { resolve: (users: { user_id: string; user_email: string | null }[]) => void; reject: (error: Error) => void } >(); vi.mocked(userFilterUICall).mockImplementation( (_accessToken, params) => new Promise((resolve, reject) => { - answers.set(params.get("user_email") ?? "", { resolve, reject }); + answers.set(params.get("search") ?? "", { resolve, reject }); }) as never, ); @@ -1099,9 +1124,7 @@ describe("CreateKey", () => { ]; vi.mocked(userFilterUICall).mockImplementation( (_accessToken, params) => - Promise.resolve( - directory.filter((entry) => entry.user_email.includes(params.get("user_email") ?? "")), - ) as never, + Promise.resolve(directory.filter((entry) => entry.user_email.includes(params.get("search") ?? ""))) as never, ); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 3127eab249e..25b986e4c9e 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -161,7 +161,7 @@ interface CreateKeyProps { interface User { user_id: string; - user_email: string; + user_email: string | null; role?: string; } @@ -570,7 +570,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setUserSearchLoading(true); try { const params = new URLSearchParams(); - params.append("user_email", searchText); // Always search by email + params.append("search", searchText); if (accessToken == null) { return; } @@ -579,7 +579,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const data: User[] = response; const options: SearchSelectOption[] = data.map((user) => ({ - label: `${user.user_email} (${user.user_id})`, + label: user.user_email ? `${user.user_email} (${user.user_id})` : user.user_id, value: user.user_id, })); @@ -729,7 +729,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp onValueChange={control.onChange} onSearchChange={fetchUsers} isLoading={userSearchLoading} - placeholder="Type email to search for users" + placeholder="Type email or user ID to search for users" emptyText="No users found" loadingText="Searching..." inputId={control.id} @@ -741,7 +741,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp Create User -
Search by email to find users
+
Search by email or user ID to find users
)} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index be1e000bd66..df294a20e19 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -17325,7 +17325,7 @@ export interface paths { }; /** * Ui View Users - * @description Filter users based on partial match of user_id or email with pagination. + * @description Filter users based on partial match of user_id or email, or combined ``search``, with pagination. * * Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag * (stored in the ``litellm_uisettings`` table): @@ -64338,6 +64338,8 @@ export interface operations { user_id?: string | null; /** @description User email in the request parameters */ user_email?: string | null; + /** @description Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive). */ + search?: string | null; /** @description Team ID — used when a team admin searches for users to add to their team */ team_id?: string | null; /** @description Page number for pagination */