feat: org admin access to team management — backend auth, UI visibility, tests

- Add _is_user_org_admin_for_team() reusable helper to common_utils.py
- Grant org admins access to /team/list, /team/info, /team/member_add,
  /team/member_delete, /team/member_update, /team/model/add,
  /team/model/delete, /team/permissions_list, /team/permissions_update
- Make validate_membership async with org admin fallback
- Add /user/list to self_managed_routes (endpoint handles own auth)
- UI: org admins see Members, Member Permissions, Settings tabs in team view
- UI: CreateUserButton uses useOrganizations() for org dropdown
- UI: org admin delete-member respects disable_team_admin_delete_team_user
- Add 16 unit tests for _is_user_org_admin_for_team, validate_membership,
  _user_is_org_admin route check, and privilege escalation prevention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang
2026-03-07 22:43:09 -08:00
co-authored by Claude Opus 4.6
parent 8bf3c0c67f
commit ce317148b9
12 changed files with 540 additions and 95 deletions
+1
View File
@@ -653,6 +653,7 @@ class LiteLLMRoutes(enum.Enum):
"/model/delete",
"/user/daily/activity",
"/user/available_roles", # read-only role metadata; any authenticated user may read
"/user/list", # org admins checked in endpoint; non-admins get 403
"/model/{model_id}/update",
"/prompt/list",
"/prompt/info",
@@ -41,6 +41,46 @@ def _is_user_team_admin(
return False
async def _is_user_org_admin_for_team(
user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable
) -> bool:
"""
Check if user is an org admin for the team's organization.
Returns True if:
- The team belongs to an organization, AND
- The user has org_admin role in that organization
"""
if not team_obj.organization_id or not user_api_key_dict.user_id:
return False
from litellm.proxy.auth.auth_checks import get_user_object
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
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,
)
if caller_user is None:
return False
for m in caller_user.organization_memberships or []:
if (
m.organization_id == team_obj.organization_id
and m.user_role == LitellmUserRoles.ORG_ADMIN.value
):
return True
return False
def _team_member_has_permission(
user_api_key_dict: UserAPIKeyAuth,
team_obj: LiteLLM_TeamTable,
@@ -70,6 +70,7 @@ from litellm.proxy.auth.auth_checks import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
_set_object_metadata_field,
_team_member_has_permission,
@@ -1649,6 +1650,9 @@ async def _validate_team_member_add_permissions(
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)
and not _is_available_team(
team_id=complete_team_data.team_id,
user_api_key_dict=user_api_key_dict,
@@ -2121,13 +2125,16 @@ async def team_member_delete(
)
existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump())
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
)
):
raise HTTPException(
status_code=403,
@@ -2280,13 +2287,16 @@ async def team_member_update(
)
existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump())
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
)
):
raise HTTPException(
status_code=403,
@@ -2760,7 +2770,7 @@ async def _persist_deleted_team_records(
prisma_client=prisma_client,
)
def validate_membership(
async def validate_membership(
user_api_key_dict: UserAPIKeyAuth, team_table: LiteLLM_TeamTable
):
if (
@@ -2795,17 +2805,26 @@ def validate_membership(
},
)
if user_api_key_dict.user_id not in [
# Check direct team membership
if user_api_key_dict.user_id in [
m.user_id for m in team_table.members_with_roles
]:
raise HTTPException(
status_code=403,
detail={
"error": "User={} not authorized to access this team={}".format(
user_api_key_dict.user_id, team_table.team_id
)
},
)
return
# Check if user is an org admin for the team's organization
if await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_table
):
return
raise HTTPException(
status_code=403,
detail={
"error": "User={} not authorized to access this team={}".format(
user_api_key_dict.user_id, team_table.team_id
)
},
)
def _unfurl_all_proxy_models(
@@ -2896,7 +2915,7 @@ async def team_info(
status_code=status.HTTP_404_NOT_FOUND,
detail={"message": f"Team not found, passed team id: {team_id}."},
)
validate_membership(
await validate_membership(
user_api_key_dict=user_api_key_dict,
team_table=LiteLLM_TeamTable(**team_info.model_dump()),
)
@@ -3384,19 +3403,11 @@ async def list_team(
- user_id: str - Optional. If passed will only return teams that the user_id is a member of.
- organization_id: str - Optional. If passed will only return teams that belong to the organization_id. Pass 'default_organization' to get all teams without organization_id.
"""
from litellm.proxy.proxy_server import prisma_client
if not allowed_route_check_inside_route(
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
):
raise HTTPException(
status_code=401,
detail={
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
raise HTTPException(
@@ -3404,6 +3415,46 @@ async def list_team(
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
# Determine access level and org-admin scoping
is_proxy_admin = _user_has_admin_view(user_api_key_dict)
allowed_org_ids: Optional[List[str]] = None
if not is_proxy_admin:
is_own_query = (
user_id is not None
and user_api_key_dict.user_id is not None
and user_api_key_dict.user_id == user_id
)
# Check if user is an org admin (even for own queries, so they see org teams)
if user_api_key_dict.user_id is not None:
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,
)
if caller_user is not None:
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:
allowed_org_ids = None
# If not an org admin and not querying own teams, reject
if allowed_org_ids is None and not is_own_query:
raise HTTPException(
status_code=401,
detail={
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
response = await prisma_client.db.litellm_teamtable.find_many(
include={
"litellm_model_table": True,
@@ -3411,8 +3462,28 @@ async def list_team(
)
filtered_response = []
if user_id:
# Get user object to access their teams array
if allowed_org_ids is not None:
# Org admin: return teams from their organizations
allowed_org_set = set(allowed_org_ids)
seen_team_ids = set()
for team in response:
if team.organization_id in allowed_org_set:
filtered_response.append(team)
seen_team_ids.add(team.team_id)
# Also include teams the user is a direct member of (outside their orgs)
if user_id:
for team in response:
if team.team_id not in seen_team_ids and team.members_with_roles:
for member in team.members_with_roles:
if (
"user_id" in member
and member["user_id"] is not None
and member["user_id"] == user_id
):
filtered_response.append(team)
seen_team_ids.add(team.team_id)
elif user_id:
# Regular user querying their own teams
for team in response:
if team.members_with_roles:
for member in team.members_with_roles:
@@ -3652,12 +3723,15 @@ async def team_model_add(
team_obj = LiteLLM_TeamTable(**team_row.model_dump())
# Authorization check - only proxy admin or team admin can add models
# Authorization check - only proxy admin, team admin, or org admin can add models
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
)
):
raise HTTPException(
status_code=403,
@@ -3720,12 +3794,15 @@ async def team_model_delete(
team_obj = LiteLLM_TeamTable(**team_row.model_dump())
# Authorization check - only proxy admin or team admin can remove models
# Authorization check - only proxy admin, team admin, or org admin can remove models
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
)
):
raise HTTPException(
status_code=403,
@@ -3770,7 +3847,7 @@ async def team_member_permissions(
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": "No db connected"})
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
existing_team_row = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
@@ -3789,6 +3866,9 @@ async def team_member_permissions(
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)
and not _is_available_team(
team_id=complete_team_data.team_id,
user_api_key_dict=user_api_key_dict,
@@ -3838,7 +3918,7 @@ async def update_team_member_permissions(
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": "No db connected"})
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
existing_team_row = await get_team_object(
team_id=data.team_id,
prisma_client=prisma_client,
@@ -3857,6 +3937,9 @@ async def update_team_member_permissions(
and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)
and not await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
)
and not _is_available_team(
team_id=complete_team_data.team_id,
user_api_key_dict=user_api_key_dict,
+1 -1
View File
@@ -5279,7 +5279,7 @@ async def get_available_models_for_user(
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object)
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object)
team_models = team_object.models
team_models = get_team_models(
@@ -0,0 +1,282 @@
"""
Tests for org admin access to team management endpoints.
Covers:
- _is_user_org_admin_for_team helper
- validate_membership allowing org admins
- _user_is_org_admin route-level check (no privilege escalation)
"""
import os
import sys
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath("../../../"))
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_TeamTable,
LiteLLM_UserTable,
LitellmUserRoles,
Member,
UserAPIKeyAuth,
)
_NOW = datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_team(team_id="team-1", organization_id="org-1") -> LiteLLM_TeamTable:
return LiteLLM_TeamTable(
team_id=team_id,
team_alias="Test Team",
organization_id=organization_id,
members_with_roles=[
Member(user_id="direct-member", role="user"),
Member(user_id="team-admin", role="admin"),
],
)
def _make_user_key(
user_id="org-admin-user", role=LitellmUserRoles.INTERNAL_USER.value
) -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id=user_id, user_role=role)
def _make_membership(user_id, org_id, role="org_admin"):
return LiteLLM_OrganizationMembershipTable(
user_id=user_id,
organization_id=org_id,
user_role=role,
created_at=_NOW,
updated_at=_NOW,
)
def _make_caller_user(
user_id="org-admin-user", org_id="org-1", org_role="org_admin"
) -> LiteLLM_UserTable:
return LiteLLM_UserTable(
user_id=user_id,
organization_memberships=[_make_membership(user_id, org_id, org_role)],
)
def _patch_org_admin_deps(get_user_return):
"""Context manager that patches the lazy imports inside _is_user_org_admin_for_team."""
return (
patch("litellm.proxy.auth.auth_checks.get_user_object", new_callable=AsyncMock, return_value=get_user_return),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock(), create=True),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(), create=True),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), create=True),
)
# ---------------------------------------------------------------------------
# _is_user_org_admin_for_team
# ---------------------------------------------------------------------------
class TestIsUserOrgAdminForTeam:
"""Tests for the reusable _is_user_org_admin_for_team helper."""
@pytest.mark.asyncio
async def test_org_admin_for_teams_org_returns_true(self):
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
)
team = _make_team(organization_id="org-1")
key = _make_user_key(user_id="org-admin-user")
caller = _make_caller_user(user_id="org-admin-user", org_id="org-1")
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
with p1, p2, p3, p4:
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
assert result is True
@pytest.mark.asyncio
async def test_org_admin_different_org_returns_false(self):
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
)
team = _make_team(organization_id="org-1")
key = _make_user_key(user_id="other-admin")
caller = _make_caller_user(user_id="other-admin", org_id="org-2")
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
with p1, p2, p3, p4:
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
assert result is False
@pytest.mark.asyncio
async def test_team_without_org_returns_false(self):
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
)
team = _make_team(organization_id=None)
key = _make_user_key()
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
assert result is False
@pytest.mark.asyncio
async def test_org_member_not_admin_returns_false(self):
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
)
team = _make_team(organization_id="org-1")
key = _make_user_key(user_id="regular")
caller = _make_caller_user(user_id="regular", org_id="org-1", org_role="user")
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
with p1, p2, p3, p4:
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
assert result is False
@pytest.mark.asyncio
async def test_no_user_id_returns_false(self):
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
)
team = _make_team(organization_id="org-1")
key = _make_user_key(user_id=None)
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
assert result is False
# ---------------------------------------------------------------------------
# validate_membership
# ---------------------------------------------------------------------------
class TestValidateMembership:
"""Tests for validate_membership with org admin support."""
@pytest.mark.asyncio
async def test_proxy_admin_allowed(self):
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
team = _make_team()
key = _make_user_key(user_id="admin", role=LitellmUserRoles.PROXY_ADMIN.value)
await validate_membership(user_api_key_dict=key, team_table=team)
@pytest.mark.asyncio
async def test_direct_team_member_allowed(self):
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
team = _make_team()
key = _make_user_key(user_id="direct-member")
await validate_membership(user_api_key_dict=key, team_table=team)
@pytest.mark.asyncio
async def test_org_admin_for_team_org_allowed(self):
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
team = _make_team(organization_id="org-1")
key = _make_user_key(user_id="org-admin-user")
caller = _make_caller_user(user_id="org-admin-user", org_id="org-1")
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
with p1, p2, p3, p4:
await validate_membership(user_api_key_dict=key, team_table=team)
@pytest.mark.asyncio
async def test_non_member_non_org_admin_rejected(self):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
team = _make_team(organization_id="org-1")
key = _make_user_key(user_id="random-user")
caller = _make_caller_user(user_id="random-user", org_id="org-2", org_role="user")
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
with p1, p2, p3, p4:
with pytest.raises(HTTPException) as exc_info:
await validate_membership(user_api_key_dict=key, team_table=team)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_team_key_matches_team_allowed(self):
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
team = _make_team(team_id="team-1")
key = UserAPIKeyAuth(team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER.value)
await validate_membership(user_api_key_dict=key, team_table=team)
# ---------------------------------------------------------------------------
# _user_is_org_admin (route-level) — no privilege escalation
# ---------------------------------------------------------------------------
class TestUserIsOrgAdminRouteCheck:
"""
Verify that _user_is_org_admin does NOT grant blanket access
when no organization_id is in the request body.
"""
def test_no_candidate_org_ids_returns_false(self):
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
user = LiteLLM_UserTable(
user_id="org-admin-user",
organization_memberships=[_make_membership("org-admin-user", "org-1")],
)
result = _user_is_org_admin(request_data={}, user_object=user)
assert result is False, "Must NOT grant blanket access when no org in request"
def test_matching_org_id_returns_true(self):
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
user = LiteLLM_UserTable(
user_id="org-admin-user",
organization_memberships=[_make_membership("org-admin-user", "org-1")],
)
result = _user_is_org_admin(request_data={"organization_id": "org-1"}, user_object=user)
assert result is True
def test_non_matching_org_id_returns_false(self):
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
user = LiteLLM_UserTable(
user_id="org-admin-user",
organization_memberships=[_make_membership("org-admin-user", "org-1")],
)
result = _user_is_org_admin(request_data={"organization_id": "org-99"}, user_object=user)
assert result is False
def test_organizations_list_field(self):
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
user = LiteLLM_UserTable(
user_id="org-admin-user",
organization_memberships=[_make_membership("org-admin-user", "org-1")],
)
result = _user_is_org_admin(
request_data={"organizations": ["org-1"]}, user_object=user
)
assert result is True
def test_none_user_object_returns_false(self):
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
result = _user_is_org_admin(request_data={}, user_object=None)
assert result is False
def test_user_list_in_self_managed_routes(self):
"""Verify /user/list is in self_managed_routes so org admins can reach it."""
from litellm.proxy._types import LiteLLMRoutes
assert "/user/list" in LiteLLMRoutes.self_managed_routes.value
@@ -278,6 +278,12 @@ const TeamsView: React.FC<TeamProps> = ({
accessToken={accessToken}
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
is_proxy_admin={userRole == "Admin"}
is_org_admin={(() => {
const team = teams?.find((t) => t.team_id === selectedTeamId);
if (!team?.organization_id || !organizations || !userID) return false;
const org = organizations.find((o) => o.organization_id === team.organization_id);
return org?.members?.some((m: any) => m.user_id === userID && m.user_role === "org_admin") ?? false;
})()}
userModels={userModels}
editTeam={editTeam}
premiumUser={premiumUser}
@@ -24,6 +24,10 @@ vi.mock("./bulk_create_users_button", () => ({
default: () => <div data-testid="bulk-create-users">Bulk Create Users</div>,
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
const mockUserCreateCall = vi.mocked(networking.userCreateCall);
const mockInvitationCreateCall = vi.mocked(networking.invitationCreateCall);
const mockGetProxyUISettings = vi.mocked(networking.getProxyUISettings);
@@ -264,7 +268,13 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
});
});
it("should send organizations list in POST body when organizationIds prop is provided", async () => {
it("should send organizations list in POST body when organizations are selected", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } });
mockInvitationCreateCall.mockResolvedValue({
@@ -273,10 +283,8 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
has_user_setup_sso: false,
} as any);
const orgIds = [{ organization_id: "org-1", organization_alias: "My Org" }];
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} organizationIds={orgIds} />,
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
@@ -288,6 +296,12 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com");
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
await user.click(screen.getByText("User"));
// Select org from the dropdown
const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i });
await user.click(orgSelect);
await user.click(screen.getByText("My Org (org-1)"));
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
await waitFor(() => {
@@ -295,13 +309,15 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
organizations: ["org-1"],
}));
});
// organization_ids should not be in the payload sent to the backend
expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.not.objectContaining({
organization_ids: expect.anything(),
}));
});
it("should not call organizationMemberAddCall after user creation", async () => {
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
vi.mocked(useOrganizations).mockReturnValue({
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
isLoading: false,
} as any);
const user = userEvent.setup();
mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } });
mockInvitationCreateCall.mockResolvedValue({
@@ -310,10 +326,8 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
has_user_setup_sso: false,
} as any);
const orgIds = [{ organization_id: "org-1", organization_alias: "My Org" }];
renderWithProviders(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} organizationIds={orgIds} />,
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
await waitFor(() => {
@@ -1,15 +1,9 @@
import { InfoCircleOutlined, UserAddOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import {
Accordion,
AccordionBody,
AccordionHeader,
Button as Button2,
SelectItem,
TextInput,
} from "@tremor/react";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { Accordion, AccordionBody, AccordionHeader, Button as Button2, SelectItem, TextInput } from "@tremor/react";
import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import BulkCreateUsers from "./bulk_create_users_button";
import TeamDropdown from "./common_components/team_dropdown";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
@@ -44,7 +38,6 @@ interface CreateuserProps {
possibleUIRoles: null | Record<string, Record<string, string>>;
onUserCreated?: (userId: string) => void;
isEmbedded?: boolean;
organizationIds?: Array<{organization_id: string, organization_alias: string}> | null;
}
// Define an interface for the UI settings
@@ -56,7 +49,13 @@ interface UISettings {
}
export const CreateUserButton: React.FC<CreateuserProps> = ({
userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationIds }) => {
userID,
accessToken,
teams,
possibleUIRoles,
onUserCreated,
isEmbedded = false,
}) => {
const queryClient = useQueryClient();
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const [form] = Form.useForm();
@@ -66,6 +65,15 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const { data: organizations = [] } = useOrganizations();
// Derive teams from the user's organizations, falling back to the teams prop
const availableTeams = useMemo(() => {
const orgTeams = organizations.flatMap((org) => org.teams || []);
if (orgTeams.length > 0) return orgTeams;
return teams || [];
}, [organizations, teams]);
useEffect(() => {
const fetchData = async () => {
try {
@@ -99,7 +107,13 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
form.resetFields();
};
const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string; organization_ids?: string[]; organizations?: string[] }) => {
const handleCreate = async (formValues: {
user_id: string;
models?: string[];
user_role: string;
organization_ids?: string[];
organizations?: string[];
}) => {
try {
NotificationsManager.info("Making API Call");
if (!isEmbedded) {
@@ -166,8 +180,8 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.
{" "}
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
@@ -197,7 +211,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</Form.Item>
<Form.Item label="Team" name="team_id">
<Select placeholder="Select Team" style={{ width: "100%" }}>
<TeamDropdown teams={teams} />
<TeamDropdown teams={availableTeams} />
</Select>
</Form.Item>
@@ -233,8 +247,8 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
message="Email invitations"
description={
<>
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.
{" "}
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
configured.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
@@ -264,11 +278,10 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<Text>
{ui_label}
</Text>
<Text>{ui_label}</Text>
<Text type="secondary">
{" - "}{description}
{" - "}
{description}
</Text>
</SelectItem>
))}
@@ -281,31 +294,22 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
name="team_id"
help="If selected, user will be added as a 'user' role to the team."
>
<TeamDropdown teams={teams} />
<TeamDropdown teams={availableTeams} />
</Form.Item>
{organizationIds && (
<Form.Item
label="Organization"
name="organization_ids"
initialValue={organizationIds.map((o) => o.organization_id)}
rules={[{ required: true, message: "Please select at least one organization" }]}
help="The user will be added to the selected organization(s)."
>
<Select
mode="multiple"
placeholder="Select Organization"
style={{ width: "100%" }}
disabled={organizationIds.length === 1}
>
{organizationIds.map((org) => (
<Option key={org.organization_id} value={org.organization_id}>
{org.organization_alias} ({org.organization_id})
</Option>
))}
</Select>
</Form.Item>
)}
<Form.Item
label="Organization"
name="organization_ids"
help="The user will be added to the selected organization(s)."
>
<Select mode="multiple" placeholder="Select Organization" style={{ width: "100%" }}>
{organizations.map((org) => (
<Option key={org.organization_id} value={org.organization_id}>
{org.organization_alias} ({org.organization_id})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
@@ -345,7 +349,9 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</AccordionBody>
</Accordion>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">Invite User</Button>
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">
Invite User
</Button>
</div>
</Form>
</Modal>
@@ -359,4 +365,4 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
)}
</div>
);
};
};
@@ -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", () => ({
@@ -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<TeamInfoProps> = ({
accessToken,
is_team_admin,
is_proxy_admin,
is_org_admin = false,
userModels,
editTeam,
premiumUser = false,
@@ -180,9 +183,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [isDeleting, setIsDeleting] = useState(false);
const [isTeamSaving, setIsTeamSaving] = useState(false);
const [organization, setOrganization] = useState<Organization | null>(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),
@@ -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)
}
/>
);
@@ -265,7 +265,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ accessToken, toke
</>
) : userID && accessToken ? (
<>
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} organizationIds={orgAdminOrgIds ?? null} />
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
{isProxyAdmin && (
<Button