fix(auth)!: scope user search while preserving team enrollment

This commit is contained in:
Tin 2026-10-27 16:25:28 -07:00
parent 969161501a
commit 22e8bf1969
9 changed files with 324 additions and 125 deletions

View file

@ -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 (

View file

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

View file

@ -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(

View file

@ -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):
"""

View file

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

View file

@ -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."
}
/>

View file

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

View file

@ -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<UserSearchModalProps> = ({
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<UserSearchModalProps> = ({
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<UserSearchModalProps> = ({
};
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<void> => {
@ -191,8 +200,8 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
<Alert variant="info" className="mb-4" data-testid="member-existing-users-notice">
<Info />
<AlertTitle>
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.
</AlertTitle>
</Alert>

View file

@ -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"];