From ab670a74f4fe9f77acfae5e600f483af26a050a9 Mon Sep 17 00:00:00 2001 From: Praveena Mundolimoole <103165192+Praveena-617@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:27:19 +0530 Subject: [PATCH] Add support for extra fields in Generic SSO via GENERIC_USER_EXTRA_ATTRIBUTES (#20761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add chat completion support for websearch * Add chat completion tool calls support and response transformation * Add new methods in chat completion * Add chat completion tool format * Add callback for websearch in completion method * Add test for web search * Potential fix for code scanning alert no. 4046: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Update litellm/integrations/websearch_interception/tools.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: empty guardrails/policies arrays should not trigger enterprise license check (#20567) * fix: empty guardrails/policies arrays should not trigger enterprise license check (#20304) The UI sends empty arrays for enterprise-only fields (guardrails, policies, logging) even when the user has not configured these features. The backend `is not None` check treated `[]` as a truthy intent to use the feature, falsely requiring an enterprise license for basic team operations. Backend: Add `and updated_kv[field] != [] and updated_kv[field] != {}` guards in `_update_metadata_fields` so empty collections are skipped. UI: Conditionally omit guardrails, logging, and policies from the payload when empty instead of defaulting to `[]`. Fixes #20304 * fix: allow clearing fields with empty collections while skipping enterprise check Address PR review feedback: 1. Move the empty-collection guard into _update_metadata_field (singular) so that empty lists/dicts skip only the premium license check but still get written into metadata. This lets users intentionally clear a previously-set field (e.g. guardrails: []) without being blocked, while the UI's default empty arrays still don't trigger a false enterprise error. 2. Remove sys.path hack from test file; use standard imports that work with pytest discovery. 3. Add tests verifying that empty collections are moved into metadata (field clearing works) even though they bypass the premium check. Fixes #20304 * fix critical CVE vulnerabliltes (#20683) * fix: add hook to handle db case (#20635) * Add team policy mapping for zguard (#20608) * support policy mapping on team key level * update document * update document * address comments * update document * add unit test for new feature * add more test case * feat: add support for anthropic_messages call type in prompt caching (#19233) * feat: add support for anthropic_messages call type in prompt caching * test: move anthropic_messages prompt caching test to main router test file * add tutorial on using claude code with prompt cache routing * docs: add SDK proxy authentication (OAuth2/JWT auto-refresh) documentation (#20680) Adds documentation for the litellm.proxy_auth feature that automatically obtains and refreshes OAuth2/JWT tokens when connecting to a LiteLLM Proxy. * Fixes #20582 (#20663) * fix: show error details instead of Data Not Available for failed requests (#20656) * fix(ui): add null guard for models in API keys table (#20655) The VirtualKeysTable crashed when rendering keys with null or undefined models field. The className expression tried to access .length on null, throwing a TypeError that broke the entire keys table. Added Array.isArray() guard before accessing .length on the models value. Fixes #20611 * Fix: Spend logs pickle error with Pydantic models and redaction (#20685) * docs: add callback registration optimization to v1.81.9 release notes (#20681) * docs: add callback registration optimization to v1.81.9 release notes * Update v1.81.9.md --------- Co-authored-by: Alexsander Hamir * Fix spend logs pickle error with Pydantic models Replace copy.deepcopy() with Pydantic-safe serialization to avoid "cannot pickle '_thread.RLock' object" errors when request/response redaction is enabled. Changes: - Add _convert_to_json_serializable_dict() helper that uses model_dump() for Pydantic models instead of pickle - Replace copy.deepcopy() calls in request and response redaction paths with the new helper function - Recursively handles nested dicts, lists, and Pydantic models Root cause: Pydantic v2 BaseModel instances contain internal _thread.RLock objects for thread-safety. When copy.deepcopy() attempts to pickle these objects, it fails because threading primitives cannot be pickled. Fixes #20647 * chore: remove unused copy import Remove unused copy import that was causing lint failure. The copy.deepcopy() calls were replaced with _convert_to_json_serializable_dict() helper function in the previous commit, making the copy module no longer needed. --------- Co-authored-by: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Co-authored-by: Alexsander Hamir * fix(vertex_ai): propagate extra_headers anthropic-beta to request body (#20666) Vertex AI requires Anthropic beta flags in the request body (anthropic_beta array), not as HTTP headers. The Bedrock handler already extracts user-specified beta headers from the headers dict, but the Vertex handler was missing this, causing extra_headers like interleaved-thinking-2025-05-14 to be silently dropped. This extracts anthropic-beta values from optional_params extra_headers and merges them into the anthropic_beta request body field, and also removes extra_headers from the request body since the parent's transform_request spreads optional_params into data. * fix(streaming): preserve interleaved thinking/redacted blocks * test(streaming): build thinking chunks with typed Delta/StreamingChoices * Fix video list pagination cursors not encoded with provider metadata first_id and last_id in the video list response were returned as raw provider IDs while data[].id was properly wrapped with encode_video_id_with_provider(). This caused pagination to break when clients passed unencoded cursors back as the `after` parameter. - Encode first_id/last_id in transform_video_list_response - Decode the `after` param in transform_video_list_request via extract_original_video_id() - Add 6 unit tests covering encoding, decoding, passthrough, and full round-trip pagination Fixes #20708 Co-Authored-By: Claude Opus 4.6 * fix(responses): preserve streamed tool deltas when id is omitted * fix(responses): guard ambiguous tool-call index reuse * Add compaction for vertex ai * Add all new feat for v1/messages * Add inference_geo as supported messages param * Add inference based costing * Add inference_geo as supported messages param * Add support for fast param * Add fast mode for other providers * Add documentation for Fast Mode * add missing indexes on VerificationToken table * Fix structured response of tool call * Add tests for WebSearch interception with chat completions API * Add doc for chat completion web search * Fix: is_web_search_tool_chat_completion * Fix double json import * Add new vercel ai anthropic models * Fix: base_model name for body and deplyment name in URL * Add output_config as supported param * Add response schema for vercel ai sonnet 4.5 * handle when litellm_parrams might be none * Fix : litellm/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py * fix: Missing return statement for async streaming * Fix: get_supported_anthropic_messages_params * Fix mypy issues * Fix mypy issues * Add support for extra fields in Generic SSO via GENERIC_USER_EXTRA_ATTRIBUTES Enables extraction of additional fields from the Generic SSO userinfo endpoint response beyond the standard 8 fields (id, email, name, etc.). Custom handlers can now access these fields via CustomOpenID.extra_fields dict. Changes: - Add extra_fields: Optional[Dict[str, Any]] to CustomOpenID type - Add GENERIC_USER_EXTRA_ATTRIBUTES env var (comma-separated field names) - Extract specified fields using get_nested_value() with dot notation support - Add 4 test cases covering basic, nested, and missing field scenarios - Update custom_sso.py example showing how to access extra_fields Backward compatible: extra_fields is None when env var not set * docs: Add documentation for GENERIC_USER_EXTRA_ATTRIBUTES Document the new GENERIC_USER_EXTRA_ATTRIBUTES environment variable for Generic SSO - Add to admin_ui_sso.md: explanation and usage examples - Add to config_settings.md: environment variable reference - Add to custom_sso.md: code example showing how to access extra_fields - Includes examples for nested field paths with dot notation --------- Co-authored-by: Sameer Kankute Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com> Co-authored-by: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Co-authored-by: jwang-gif Co-authored-by: nuernber Co-authored-by: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Co-authored-by: John Lathouwers Co-authored-by: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Co-authored-by: Alexsander Hamir Co-authored-by: Elias Högbom Aronsson Co-authored-by: Emerson Gomes Co-authored-by: tshushan Co-authored-by: Claude Opus 4.6 Co-authored-by: Carlo Alberto Ferraris --- docs/my-website/docs/proxy/admin_ui_sso.md | 35 + docs/my-website/docs/proxy/config_settings.md | 1 + docs/my-website/docs/proxy/custom_sso.md | 12 + litellm/proxy/custom_sso.py | 9 +- litellm/proxy/management_endpoints/types.py | 3 +- litellm/proxy/management_endpoints/ui_sso.py | 11 + .../proxy/management_endpoints/test_ui_sso.py | 120 ++ .../src/components/team/team_info.tsx | 1296 +++++++++++++++++ 8 files changed, 1483 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/team/team_info.tsx diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index 37e45b50284..f88d3480446 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -223,6 +223,7 @@ GENERIC_USER_FIRST_NAME_ATTRIBUTE = "first_name" GENERIC_USER_LAST_NAME_ATTRIBUTE = "last_name" GENERIC_USER_ROLE_ATTRIBUTE = "given_role" GENERIC_USER_PROVIDER_ATTRIBUTE = "provider" +GENERIC_USER_EXTRA_ATTRIBUTES = "department,employee_id,manager" # comma-separated list of additional fields to extract from SSO response GENERIC_CLIENT_STATE = "some-state" # if the provider needs a state parameter GENERIC_INCLUDE_CLIENT_ID = "false" # some providers enforce that the client_id is not in the body GENERIC_SCOPE = "openid profile email" # default scope openid is sometimes not enough to retrieve basic user info like first_name and last_name located in profile scope @@ -239,6 +240,40 @@ Use `GENERIC_USER_ROLE_ATTRIBUTE` to specify which attribute in the SSO token co Nested attribute paths are supported (e.g., `claims.role` or `attributes.litellm_role`). +**Capturing Additional SSO Fields** + +Use `GENERIC_USER_EXTRA_ATTRIBUTES` to extract additional fields from the SSO provider response beyond the standard user attributes (id, email, name, etc.). This is useful when you need to access custom organization-specific data (e.g., department, employee ID, groups) in your [custom SSO handler](./custom_sso.md). + +```shell +# Comma-separated list of field names to extract +GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,manager,groups" +``` + +**Accessing Extra Fields in Custom SSO Handler:** + +```python +from litellm.proxy.management_endpoints.types import CustomOpenID + +async def custom_sso_handler(userIDPInfo: CustomOpenID): + # Access the extra fields + extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {} + + user_department = extra_fields.get("department") + employee_id = extra_fields.get("employee_id") + user_groups = extra_fields.get("groups", []) + + # Use these fields for custom logic (e.g., team assignment, access control) + # ... +``` + +**Nested Field Paths:** + +Dot notation is supported for nested fields: + +```shell +GENERIC_USER_EXTRA_ATTRIBUTES="org_info.department,org_info.cost_center,metadata.employee_type" +``` + - Set Redirect URI, if your provider requires it - Set a redirect url = `/sso/callback` ```shell diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 5cdae51f448..c78a5c9243e 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -640,6 +640,7 @@ router_settings: | GENERIC_TOKEN_ENDPOINT | Token endpoint for generic OAuth providers | GENERIC_USER_DISPLAY_NAME_ATTRIBUTE | Attribute for user's display name in generic auth | GENERIC_USER_EMAIL_ATTRIBUTE | Attribute for user's email in generic auth +| GENERIC_USER_EXTRA_ATTRIBUTES | Comma-separated list of additional fields to extract from generic SSO provider response (e.g., "department,employee_id,groups"). Accessible via `CustomOpenID.extra_fields` in custom SSO handlers. Supports dot notation for nested fields | GENERIC_USER_FIRST_NAME_ATTRIBUTE | Attribute for user's first name in generic auth | GENERIC_USER_ID_ATTRIBUTE | Attribute for user ID in generic auth | GENERIC_USER_LAST_NAME_ATTRIBUTE | Attribute for user's last name in generic auth diff --git a/docs/my-website/docs/proxy/custom_sso.md b/docs/my-website/docs/proxy/custom_sso.md index bbd7f41bee1..8b7adeb0c5a 100644 --- a/docs/my-website/docs/proxy/custom_sso.md +++ b/docs/my-website/docs/proxy/custom_sso.md @@ -142,6 +142,18 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: f"No ID found for user. userIDPInfo.id is None {userIDPInfo}" ) + ################################################# + # Access extra fields from SSO provider (requires GENERIC_USER_EXTRA_ATTRIBUTES env var) + # Example: Set GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,groups" + extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {} + user_department = extra_fields.get("department") + employee_id = extra_fields.get("employee_id") + user_groups = extra_fields.get("groups", []) + + print(f"User department: {user_department}") # noqa + print(f"Employee ID: {employee_id}") # noqa + print(f"User groups: {user_groups}") # noqa + ################################################# ################################################# # Run your custom code / logic here diff --git a/litellm/proxy/custom_sso.py b/litellm/proxy/custom_sso.py index 210e9eea3dc..b2b028dfbe3 100644 --- a/litellm/proxy/custom_sso.py +++ b/litellm/proxy/custom_sso.py @@ -24,9 +24,12 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: print(f"userIDPInfo: {userIDPInfo}") # noqa if userIDPInfo.id is None: - raise ValueError( - f"No ID found for user. userIDPInfo.id is None {userIDPInfo}" - ) + raise ValueError(f"No ID found for user. userIDPInfo.id is None {userIDPInfo}") + + # Access extra fields from the IDP response (requires GENERIC_USER_EXTRA_ATTRIBUTES env var) + # Example: Set GENERIC_USER_EXTRA_ATTRIBUTES="group,NTID,domain" to capture these fields + # extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {} + # user_groups = extra_fields.get("group", []) # check if user exists in litellm proxy DB _user_info = await user_info(user_id=userIDPInfo.id) diff --git a/litellm/proxy/management_endpoints/types.py b/litellm/proxy/management_endpoints/types.py index a35fc4a5f3f..295c2ad50b3 100644 --- a/litellm/proxy/management_endpoints/types.py +++ b/litellm/proxy/management_endpoints/types.py @@ -4,7 +4,7 @@ Types for the management endpoints Might include fastapi/proxy requirements.txt related imports """ -from typing import List, Optional, cast +from typing import Any, Dict, List, Optional, cast from fastapi_sso.sso.base import OpenID @@ -56,3 +56,4 @@ def get_litellm_user_role(role_str) -> Optional[LitellmUserRoles]: class CustomOpenID(OpenID): team_ids: List[str] user_role: Optional[LitellmUserRoles] = None + extra_fields: Optional[Dict[str, Any]] = None diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 278f3bdaafd..823f6400226 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -401,6 +401,8 @@ def generic_response_convertor( generic_user_role_attribute_name = os.getenv("GENERIC_USER_ROLE_ATTRIBUTE", "role") + generic_user_extra_attributes = os.getenv("GENERIC_USER_EXTRA_ATTRIBUTES", None) + verbose_proxy_logger.debug( f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}" ) @@ -473,6 +475,14 @@ def generic_response_convertor( f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'" ) + # Build extra_fields dict from GENERIC_USER_EXTRA_ATTRIBUTES if specified + extra_fields = None + if generic_user_extra_attributes: + extra_fields = {} + for attr_name in generic_user_extra_attributes.split(","): + attr_name = attr_name.strip() + extra_fields[attr_name] = get_nested_value(response, attr_name) + return CustomOpenID( id=get_nested_value(response, generic_user_id_attribute_name), display_name=get_nested_value( @@ -484,6 +494,7 @@ def generic_response_convertor( provider=get_nested_value(response, generic_provider_attribute_name), team_ids=all_teams, user_role=user_role, + extra_fields=extra_fields, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 16f80826798..5791f9d36cd 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -4071,3 +4071,123 @@ def test_process_sso_jwt_access_token_with_role_mappings(): # Should get highest privilege role assert result.user_role == LitellmUserRoles.PROXY_ADMIN + +def test_generic_response_convertor_with_extra_attributes(monkeypatch): + """Test that extra attributes are extracted when GENERIC_USER_EXTRA_ATTRIBUTES is set""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "custom_field1,custom_field2,custom_field3") + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + "given_name": "John", + "family_name": "Doe", + "name": "John Doe", + "provider": "generic", + "custom_field1": "value1", + "custom_field2": ["item1", "item2"], + "custom_field3": {"nested": "data"}, + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is not None + assert result.extra_fields["custom_field1"] == "value1" + assert result.extra_fields["custom_field2"] == ["item1", "item2"] + assert result.extra_fields["custom_field3"] == {"nested": "data"} + +def test_generic_response_convertor_without_extra_attributes(monkeypatch): + """Test backward compatibility - extra_fields is None when env var not set""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + # Don't set GENERIC_USER_EXTRA_ATTRIBUTES + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + "given_name": "John", + "family_name": "Doe", + "name": "John Doe", + "provider": "generic", + "custom_field1": "value1", + "custom_field2": "value2", + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is None + +def test_generic_response_convertor_extra_attributes_with_nested_paths(monkeypatch): + """Test that nested paths work with dot notation""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "org_info.department,org_info.manager") + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + "org_info": { + "department": "Engineering", + "manager": "Jane Smith" + } + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is not None + assert result.extra_fields["org_info.department"] == "Engineering" + assert result.extra_fields["org_info.manager"] == "Jane Smith" + +def test_generic_response_convertor_extra_attributes_missing_field(monkeypatch): + """Test that missing fields return None""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "missing_field,another_missing") + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is not None + assert result.extra_fields["missing_field"] is None + assert result.extra_fields["another_missing"] is None \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx new file mode 100644 index 00000000000..014f8fb9010 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -0,0 +1,1296 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import UserSearchModal from "@/components/common_components/user_search_modal"; +import { + getGuardrailsList, + getPoliciesList, + getPolicyInfoWithGuardrails, + Member, + Organization, + organizationInfoCall, + teamInfoCall, + teamMemberAddCall, + teamMemberDeleteCall, + teamMemberUpdateCall, + teamUpdateCall, +} from "@/components/networking"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; +import { isProxyAdminRole } from "@/utils/roles"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { + Badge, + Card, + Grid, + Tab, + TabGroup, + TabList, + TabPanel, + TabPanels, + Text, + TextInput, + Title, + Button as TremorButton, +} from "@tremor/react"; +import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; +import AgentSelector from "../agent_management/AgentSelector"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import DurationSelect from "../common_components/DurationSelect"; +import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; +import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; +import LoggingSettingsView from "../logging_settings_view"; +import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; +import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; +import { ModelSelect } from "../ModelSelect/ModelSelect"; +import NotificationsManager from "../molecules/notifications_manager"; +import { fetchMCPAccessGroups } from "../networking"; +import ObjectPermissionsView from "../object_permissions_view"; +import NumericalInput from "../shared/numerical_input"; +import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; +import EditLoggingSettings from "./EditLoggingSettings"; +import MemberModal from "./EditMembership"; +import MemberPermissions from "./member_permissions"; +import TeamMembersComponent from "./team_member_view"; + +export interface TeamMembership { + user_id: string; + team_id: string; + budget_id: string; + spend: number; + litellm_budget_table: { + budget_id: string; + soft_budget: number | null; + max_budget: number | null; + max_parallel_requests: number | null; + tpm_limit: number | null; + rpm_limit: number | null; + model_max_budget: Record | null; + budget_duration: string | null; + }; +} + +export interface TeamData { + team_id: string; + team_info: { + team_alias: string; + team_id: string; + organization_id: string | null; + admins: string[]; + members: string[]; + members_with_roles: Member[]; + metadata: Record; + tpm_limit: number | null; + rpm_limit: number | null; + max_budget: number | null; + soft_budget?: number | null; + budget_duration: string | null; + models: string[]; + blocked: boolean; + spend: number; + max_parallel_requests: number | null; + budget_reset_at: string | null; + model_id: string | null; + litellm_model_table: { + model_aliases: Record; + } | null; + created_at: string; + guardrails?: string[]; + policies?: string[]; + object_permission?: { + object_permission_id: string; + mcp_servers: string[]; + mcp_access_groups?: string[]; + mcp_tool_permissions?: Record; + vector_stores: string[]; + agents?: string[]; + agent_access_groups?: string[]; + }; + team_member_budget_table: { + max_budget: number; + budget_duration: string; + tpm_limit: number | null; + rpm_limit: number | null; + } | null; + }; + keys: any[]; + team_memberships: TeamMembership[]; +} + +export interface TeamInfoProps { + teamId: string; + onUpdate: (data: any) => void; + onClose: () => void; + accessToken: string | null; + is_team_admin: boolean; + is_proxy_admin: boolean; + userModels: string[]; + editTeam: boolean; + premiumUser?: boolean; +} + +const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { + let tempModelsToPick = []; + + if (organization) { + // Check if organization has "all-proxy-models" in its models array + if (organization.models.includes("all-proxy-models")) { + // Treat as all-proxy-models (use userModels) + tempModelsToPick = userModels; + } else if (organization.models.length > 0) { + // Organization has specific models + tempModelsToPick = organization.models; + } else { + // Empty array [] is treated as all-proxy-models + tempModelsToPick = userModels; + } + } else { + // No organization, show all available models + tempModelsToPick = userModels; + } + + return unfurlWildcardModelsInList(tempModelsToPick, userModels); +}; + +const TeamInfoView: React.FC = ({ + teamId, + onClose, + accessToken, + is_team_admin, + is_proxy_admin, + userModels, + editTeam, + premiumUser = false, + onUpdate, +}) => { + const [teamData, setTeamData] = useState(null); + const [loading, setLoading] = useState(true); + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [form] = Form.useForm(); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); + const [selectedEditMember, setSelectedEditMember] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); + const [copiedStates, setCopiedStates] = useState>({}); + const [guardrailsList, setGuardrailsList] = useState([]); + const [policiesList, setPoliciesList] = useState([]); + const [policyGuardrails, setPolicyGuardrails] = useState>({}); + const [loadingPolicies, setLoadingPolicies] = useState(false); + const [memberToDelete, setMemberToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isTeamSaving, setIsTeamSaving] = useState(false); + const [organization, setOrganization] = useState(null); + const { userRole } = useAuthorized(); + + const canEditTeam = is_team_admin || is_proxy_admin; + + const fetchTeamInfo = async () => { + try { + setLoading(true); + if (!accessToken) return; + const response = await teamInfoCall(accessToken, teamId); + setTeamData(response); + } catch (error) { + NotificationsManager.fromBackend("Failed to load team information"); + console.error("Error fetching team info:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTeamInfo(); + }, [teamId, accessToken]); + + // Fetch organization data when team has organization_id + useEffect(() => { + const fetchOrganization = async () => { + if (!accessToken || !teamData?.team_info?.organization_id) { + setOrganization(null); + return; + } + + try { + const orgData = await organizationInfoCall(accessToken, teamData.team_info.organization_id); + setOrganization(orgData); + } catch (error) { + console.error("Error fetching organization info:", error); + setOrganization(null); + } + }; + + fetchOrganization(); + }, [accessToken, teamData?.team_info?.organization_id]); + + // Compute modelsToPick based on organization and userModels + const modelsToPick = useMemo(() => { + return getOrganizationModels(organization, userModels); + }, [organization, userModels]); + + const fetchMcpAccessGroups = async () => { + if (!accessToken) return; + if (mcpAccessGroupsLoaded) return; + try { + const groups = await fetchMCPAccessGroups(accessToken); + setMcpAccessGroups(groups); + setMcpAccessGroupsLoaded(true); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + } + }; + + useEffect(() => { + const fetchGuardrails = async () => { + try { + if (!accessToken) return; + const response = await getGuardrailsList(accessToken); + const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + setGuardrailsList(guardrailNames); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + } + }; + + const fetchPolicies = async () => { + try { + if (!accessToken) return; + const response = await getPoliciesList(accessToken); + const policyNames = response.policies.map((p: { policy_name: string }) => p.policy_name); + setPoliciesList(policyNames); + } catch (error) { + console.error("Failed to fetch policies:", error); + } + }; + + fetchGuardrails(); + fetchPolicies(); + }, [accessToken]); + + // Fetch resolved guardrails for all policies + useEffect(() => { + const fetchPolicyGuardrails = async () => { + if (!accessToken || !teamData?.team_info?.policies || teamData.team_info.policies.length === 0) { + return; + } + + setLoadingPolicies(true); + const guardrailsMap: Record = {}; + + try { + await Promise.all( + teamData.team_info.policies.map(async (policyName: string) => { + try { + const policyInfo = await getPolicyInfoWithGuardrails(accessToken, policyName); + guardrailsMap[policyName] = policyInfo.resolved_guardrails || []; + } catch (error) { + console.error(`Failed to fetch guardrails for policy ${policyName}:`, error); + guardrailsMap[policyName] = []; + } + }) + ); + setPolicyGuardrails(guardrailsMap); + } catch (error) { + console.error("Failed to fetch policy guardrails:", error); + } finally { + setLoadingPolicies(false); + } + }; + + fetchPolicyGuardrails(); + }, [accessToken, teamData?.team_info?.policies]); + + const handleMemberCreate = async (values: any) => { + try { + if (accessToken == null) return; + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + }; + + await teamMemberAddCall(accessToken, teamId, member); + + NotificationsManager.success("Team member added successfully"); + setIsAddMemberModalVisible(false); + form.resetFields(); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error: any) { + let errMsg = "Failed to add team member"; + + if (error?.raw?.detail?.error?.includes("Assigning team admins is a premium feature")) { + errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; + } else if (error?.message) { + errMsg = error.message; + } + + NotificationsManager.fromBackend(errMsg); + console.error("Error adding team member:", error); + } + }; + + const handleMemberUpdate = async (values: any) => { + try { + if (accessToken == null) { + return; + } + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + max_budget_in_team: values.max_budget_in_team, + tpm_limit: values.tpm_limit, + rpm_limit: values.rpm_limit, + }; + console.log("Updating member with values:", member); + message.destroy(); // Remove all existing toasts + + await teamMemberUpdateCall(accessToken, teamId, member); + + NotificationsManager.success("Team member updated successfully"); + setIsEditMemberModalVisible(false); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error: any) { + let errMsg = "Failed to update team member"; + if (error?.raw?.detail?.includes("Assigning team admins is a premium feature")) { + errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; + } else if (error?.message) { + errMsg = error.message; + } + setIsEditMemberModalVisible(false); + + message.destroy(); // Remove all existing toasts + + NotificationsManager.fromBackend(errMsg); + console.error("Error updating team member:", error); + } + }; + + const handleMemberDelete = (member: Member) => { + setMemberToDelete(member); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!memberToDelete || !accessToken) return; + + setIsDeleting(true); + try { + await teamMemberDeleteCall(accessToken, teamId, memberToDelete); + + NotificationsManager.success("Team member removed successfully"); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error) { + NotificationsManager.fromBackend("Failed to remove team member"); + console.error("Error removing team member:", error); + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + setMemberToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setIsDeleteModalOpen(false); + setMemberToDelete(null); + }; + + const handleTeamUpdate = async (values: any) => { + try { + if (!accessToken) return; + setIsTeamSaving(true); + + let parsedMetadata = {}; + try { + const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {}; + // Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately + const { soft_budget_alerting_emails, ...rest } = rawMetadata; + parsedMetadata = rest; + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in metadata field"); + return; + } + + let secretManagerSettings: Record | undefined; + if (typeof values.secret_manager_settings === "string") { + const trimmedSecretConfig = values.secret_manager_settings.trim(); + if (trimmedSecretConfig.length > 0) { + try { + secretManagerSettings = JSON.parse(values.secret_manager_settings); + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in secret manager settings"); + return; + } + } + } + + const sanitizeNumeric = (v: any) => { + if (v === null || v === undefined) return null; + if (typeof v === "string" && v.trim() === "") return null; + if (typeof v === "number" && Number.isNaN(v)) return null; + return v; + }; + + const updateData: any = { + team_id: teamId, + team_alias: values.team_alias, + models: values.models, + tpm_limit: sanitizeNumeric(values.tpm_limit), + rpm_limit: sanitizeNumeric(values.rpm_limit), + max_budget: values.max_budget, + soft_budget: sanitizeNumeric(values.soft_budget), + budget_duration: values.budget_duration, + metadata: { + ...parsedMetadata, + ...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}), + ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), + disable_global_guardrails: values.disable_global_guardrails || false, + soft_budget_alerting_emails: + typeof values.soft_budget_alerting_emails === "string" + ? values.soft_budget_alerting_emails + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email.length > 0) + : values.soft_budget_alerting_emails || [], + ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), + }, + ...(values.policies?.length > 0 ? { policies: values.policies } : {}), + organization_id: values.organization_id, + }; + + updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); + updateData.team_member_budget_duration = values.team_member_budget_duration; + + if (values.team_member_budget !== undefined) { + updateData.team_member_budget = Number(values.team_member_budget); + } + + if (values.team_member_key_duration !== undefined) { + updateData.team_member_key_duration = values.team_member_key_duration; + } + + if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) { + updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit); + updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit); + } + + // Handle object_permission updates + const { servers, accessGroups } = values.mcp_servers_and_groups || { + servers: [], + accessGroups: [], + }; + const serverIds = new Set(servers || []); + const mcpToolPermissions = Object.fromEntries( + Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)), + ); + + updateData.object_permission = {}; + if (servers) { + updateData.object_permission.mcp_servers = servers; + } + if (accessGroups) { + updateData.object_permission.mcp_access_groups = accessGroups; + } + if (mcpToolPermissions) { + updateData.object_permission.mcp_tool_permissions = mcpToolPermissions; + } + delete values.mcp_servers_and_groups; + delete values.mcp_tool_permissions; + + // Handle agent permissions + const { agents, accessGroups: agentAccessGroups } = values.agents_and_groups || { + agents: [], + accessGroups: [], + }; + if (agents && agents.length > 0) { + updateData.object_permission.agents = agents; + } + if (agentAccessGroups && agentAccessGroups.length > 0) { + updateData.object_permission.agent_access_groups = agentAccessGroups; + } + delete values.agents_and_groups; + + // Handle vector stores permissions + if (values.vector_stores && values.vector_stores.length > 0) { + updateData.object_permission.vector_stores = values.vector_stores; + } + + const response = await teamUpdateCall(accessToken, updateData); + + NotificationsManager.success("Team settings updated successfully"); + setIsEditing(false); + fetchTeamInfo(); + } catch (error) { + console.error("Error updating team:", error); + } finally { + setIsTeamSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + if (!teamData?.team_info) { + return
Team not found
; + } + + const { team_info: info } = teamData; + + const copyToClipboard = async (text: string, key: string) => { + const success = await utilCopyToClipboard(text); + if (success) { + setCopiedStates((prev) => ({ ...prev, [key]: true })); + setTimeout(() => { + setCopiedStates((prev) => ({ ...prev, [key]: false })); + }, 2000); + } + }; + + return ( +
+
+
+ + Back to Teams + + {info.team_alias} +
+ {info.team_id} +
+
+
+ + + + {[ + Overview, + ...(canEditTeam + ? [ + Members, + Member Permissions, + Settings, + ] + : []), + ]} + + + + {/* Overview Panel */} + + + + Budget Status +
+ ${formatNumberWithCommas(info.spend, 4)} + + of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} + + {info.budget_duration && Reset: {info.budget_duration}} +
+ {info.team_member_budget_table && ( + + Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} + + )} +
+
+ + + Rate Limits +
+ TPM: {info.tpm_limit || "Unlimited"} + RPM: {info.rpm_limit || "Unlimited"} + {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} +
+
+ + + Models +
+ {info.models.length === 0 ? ( + All proxy models + ) : ( + info.models.map((model, index) => ( + + {model} + + )) + )} +
+
+ + + Virtual Keys +
+ User Keys: {teamData.keys.filter((key) => key.user_id).length} + Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length} + Total: {teamData.keys.length} +
+
+ + + + + Guardrails + {info.guardrails && info.guardrails.length > 0 ? ( +
+ {info.guardrails.map((guardrail: string, index: number) => ( + + {guardrail} + + ))} +
+ ) : ( + No guardrails configured + )} + {info.metadata?.disable_global_guardrails && ( +
+ Global Guardrails Disabled +
+ )} +
+ + + Policies + {info.policies && info.policies.length > 0 ? ( +
+ {info.policies.map((policy: string, index: number) => ( +
+
+ {policy} + {loadingPolicies && Loading guardrails...} +
+ {!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && ( +
+ Resolved Guardrails: +
+ {policyGuardrails[policy].map((guardrail: string, gIndex: number) => ( + + {guardrail} + + ))} +
+
+ )} +
+ ))} +
+ ) : ( + No policies configured + )} +
+ + +
+
+ + {/* Members Panel */} + + + + + {/* Member Permissions Panel */} + {canEditTeam && ( + + + + )} + + {/* Settings Panel */} + + +
+ Team Settings + {canEditTeam && !isEditing && ( + setIsEditing(true)}>Edit Settings + )} +
+ + {isEditing ? ( +
rest)(info.metadata), + null, + 2, + ) + : "", + logging_settings: info.metadata?.logging || [], + secret_manager_settings: info.metadata?.secret_manager_settings + ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) + : "", + organization_id: info.organization_id, + vector_stores: info.object_permission?.vector_stores || [], + mcp_servers: info.object_permission?.mcp_servers || [], + mcp_access_groups: info.object_permission?.mcp_access_groups || [], + mcp_servers_and_groups: { + servers: info.object_permission?.mcp_servers || [], + accessGroups: info.object_permission?.mcp_access_groups || [], + }, + mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {}, + agents_and_groups: { + agents: info.object_permission?.agents || [], + accessGroups: info.object_permission?.agent_access_groups || [], + }, + }} + layout="vertical" + > + + + + + + form.setFieldValue("models", values)} + teamID={teamId} + organizationID={teamData?.team_info?.organization_id || undefined} + options={{ + includeSpecialOptions: true, + includeUserModels: !teamData?.team_info?.organization_id, + showAllProxyModelsOverride: isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id, + }} + context="team" + dataTestId="models-select" + /> + + + + + + + + + + + + + + + + + + + + form.setFieldValue("team_member_budget_duration", value)} + value={form.getFieldValue("team_member_budget_duration")} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Guardrails{" "} + + e.stopPropagation()} + > + + + + + } + name="guardrails" + help="Select existing guardrails or enter new ones" + > + ({ value: name, label: name }))} + /> + + + + form.setFieldValue("vector_stores", values)} + value={form.getFieldValue("vector_stores")} + accessToken={accessToken || ""} + placeholder="Select vector stores" + /> + + + + form.setFieldValue("allowed_passthrough_routes", values)} + value={form.getFieldValue("allowed_passthrough_routes")} + accessToken={accessToken || ""} + placeholder="Select pass through routes" + /> + + + + form.setFieldValue("mcp_servers_and_groups", val)} + value={form.getFieldValue("mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + {/* Hidden field to register mcp_tool_permissions with the form */} + + + + prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+ + + form.setFieldValue("agents_and_groups", val)} + value={form.getFieldValue("agents_and_groups")} + accessToken={accessToken || ""} + placeholder="Select agents or access groups (optional)" + /> + + + + + + + + form.setFieldValue("logging_settings", values)} + /> + + + { + if (!value) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + + + + + + +
+
+ setIsEditing(false)} disabled={isTeamSaving}> + Cancel + + + Save Changes + +
+
+
+ ) : ( +
+
+ Team Name +
{info.team_alias}
+
+
+ Team ID +
{info.team_id}
+
+
+ Created At +
{new Date(info.created_at).toLocaleString()}
+
+
+ Models +
+ {info.models.map((model, index) => ( + + {model} + + ))} +
+
+
+ Rate Limits +
TPM: {info.tpm_limit || "Unlimited"}
+
RPM: {info.rpm_limit || "Unlimited"}
+
+
+ Team Budget +
+ Max Budget:{" "} + {info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"} +
+
+ Soft Budget:{" "} + {info.soft_budget !== null && info.soft_budget !== undefined + ? `$${formatNumberWithCommas(info.soft_budget, 4)}` + : "No Limit"} +
+
Budget Reset: {info.budget_duration || "Never"}
+ {info.metadata?.soft_budget_alerting_emails && + Array.isArray(info.metadata.soft_budget_alerting_emails) && + info.metadata.soft_budget_alerting_emails.length > 0 && ( +
+ Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")} +
+ )} +
+
+ + Team Member Settings{" "} + + + + +
Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}
+
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
+
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
+
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
+
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
+
+
+ Organization ID +
{info.organization_id}
+
+
+ Status + {info.blocked ? "Blocked" : "Active"} +
+ +
+ Disable Global Guardrails +
+ {info.metadata?.disable_global_guardrails === true ? ( + Enabled - Global guardrails bypassed + ) : ( + Disabled - Global guardrails active + )} +
+
+ + + + + + {info.metadata?.secret_manager_settings && ( +
+ Secret Manager Settings +
+                        {JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
+                      
+
+ )} +
+ )} +
+
+
+
+ + setIsEditMemberModalVisible(false)} + onSubmit={handleMemberUpdate} + initialData={selectedEditMember} + mode="edit" + config={{ + title: "Edit Member", + showEmail: true, + showUserId: true, + roleOptions: [ + { label: "Admin", value: "admin" }, + { label: "User", value: "user" }, + ], + additionalFields: [ + { + name: "max_budget_in_team", + label: ( + + Team Member Budget (USD){" "} + + + + + ), + type: "numerical" as const, + step: 0.01, + min: 0, + placeholder: "Budget limit for this member within this team", + }, + { + name: "tpm_limit", + label: ( + + Team Member TPM Limit{" "} + + + + + ), + type: "numerical" as const, + step: 1, + min: 0, + placeholder: "Tokens per minute limit for this member in this team", + }, + { + name: "rpm_limit", + label: ( + + Team Member RPM Limit{" "} + + + + + ), + type: "numerical" as const, + step: 1, + min: 0, + placeholder: "Requests per minute limit for this member in this team", + }, + ], + }} + /> + + setIsAddMemberModalVisible(false)} + onSubmit={handleMemberCreate} + accessToken={accessToken} + /> + + {/* Delete Member Confirmation Modal */} + +
+ ); +}; + +export default TeamInfoView;