mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-24 02:25:29 +00:00
Merge pull request #23614 from BerriAI/litellm_team_default_permissions
[Feature] UI - Default Team Settings: Modernize page and fix defaults application
This commit is contained in:
@@ -1417,6 +1417,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int(
|
||||
)
|
||||
LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
|
||||
"default_internal_user_params",
|
||||
"default_team_params",
|
||||
"public_mcp_servers",
|
||||
"public_agent_groups",
|
||||
"public_model_groups",
|
||||
|
||||
@@ -252,6 +252,28 @@ class TeamMemberBudgetHandler:
|
||||
data_dict.pop("team_member_tpm_limit", None)
|
||||
|
||||
|
||||
def _get_default_team_param(field: str) -> Any:
|
||||
"""
|
||||
Returns a default value for the given field from litellm.default_team_params config.
|
||||
Returns None if no default is configured.
|
||||
|
||||
For list fields containing enums (e.g. team_member_permissions), converts enum values to strings.
|
||||
"""
|
||||
default_params = litellm.default_team_params
|
||||
if default_params is None:
|
||||
return None
|
||||
if isinstance(default_params, dict):
|
||||
value = default_params.get(field)
|
||||
else:
|
||||
value = getattr(default_params, field, None)
|
||||
if value is None:
|
||||
return None
|
||||
# Convert enum values in lists to strings
|
||||
if isinstance(value, list):
|
||||
return [v.value if hasattr(v, "value") else v for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _is_available_team(team_id: str, user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
if litellm.default_internal_user_params is None:
|
||||
return False
|
||||
@@ -833,16 +855,23 @@ async def new_team( # noqa: PLR0915
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
# If max_budget is not explicitly provided in the request,
|
||||
# check for a default value in the proxy configuration.
|
||||
# Apply defaults from litellm.default_team_params for any fields
|
||||
# not explicitly provided in the request.
|
||||
for field in ("max_budget", "budget_duration", "tpm_limit", "rpm_limit", "team_member_permissions"):
|
||||
if getattr(data, field, None) is None:
|
||||
default_value = _get_default_team_param(field)
|
||||
if default_value is not None:
|
||||
setattr(data, field, default_value)
|
||||
|
||||
# Legacy fallback: apply max_budget from default_team_settings (YAML config)
|
||||
# if still not set after checking default_team_params.
|
||||
if data.max_budget is None:
|
||||
if (
|
||||
isinstance(litellm.default_team_settings, list)
|
||||
and len(litellm.default_team_settings) > 0
|
||||
and isinstance(litellm.default_team_settings[0], dict)
|
||||
):
|
||||
default_settings = litellm.default_team_settings[0]
|
||||
default_budget = default_settings.get("max_budget")
|
||||
default_budget = litellm.default_team_settings[0].get("max_budget")
|
||||
if default_budget is not None:
|
||||
data.max_budget = default_budget
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ from litellm.proxy.auth.auth_checks import get_team_object
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
DEFAULT_TEAM_MEMBER_PERMISSIONS = [
|
||||
BASELINE_TEAM_MEMBER_PERMISSIONS = [
|
||||
KeyManagementRoutes.KEY_INFO,
|
||||
KeyManagementRoutes.KEY_HEALTH,
|
||||
]
|
||||
|
||||
DEFAULT_TEAM_MEMBER_PERMISSIONS = BASELINE_TEAM_MEMBER_PERMISSIONS
|
||||
|
||||
|
||||
class TeamMemberPermissionChecks:
|
||||
@staticmethod
|
||||
@@ -29,15 +31,23 @@ class TeamMemberPermissionChecks:
|
||||
team_table: LiteLLM_TeamTableCachedObj,
|
||||
) -> List[KeyManagementRoutes]:
|
||||
"""
|
||||
Returns the permissions for a team member
|
||||
Returns the permissions for a team member.
|
||||
|
||||
- If team has explicit permissions set (including []), use those
|
||||
plus baseline permissions (/key/info, /key/health).
|
||||
- If team has no permissions set (None), fall back to
|
||||
DEFAULT_TEAM_MEMBER_PERMISSIONS.
|
||||
"""
|
||||
if team_table.team_member_permissions and isinstance(
|
||||
if team_table.team_member_permissions is not None and isinstance(
|
||||
team_table.team_member_permissions, list
|
||||
):
|
||||
return [
|
||||
permissions = {
|
||||
KeyManagementRoutes(permission)
|
||||
for permission in team_table.team_member_permissions
|
||||
]
|
||||
}
|
||||
# Always include baseline permissions
|
||||
permissions.update(BASELINE_TEAM_MEMBER_PERMISSIONS)
|
||||
return list(permissions)
|
||||
|
||||
return DEFAULT_TEAM_MEMBER_PERMISSIONS
|
||||
|
||||
|
||||
@@ -328,11 +328,34 @@ async def _get_settings_with_schema(
|
||||
}
|
||||
|
||||
# Add property descriptions
|
||||
defs = schema.get("$defs", schema.get("definitions", {}))
|
||||
for field_name, field_info in schema["properties"].items():
|
||||
result["field_schema"]["properties"][field_name] = {
|
||||
# For Optional fields, Pydantic v2 uses anyOf with [actual_type, null].
|
||||
# Resolve the non-null variant to get the real type and items.
|
||||
resolved = field_info
|
||||
if "anyOf" in field_info:
|
||||
for variant in field_info["anyOf"]:
|
||||
if variant.get("type") != "null":
|
||||
resolved = variant
|
||||
break
|
||||
|
||||
prop_entry: dict = {
|
||||
"description": field_info.get("description", ""),
|
||||
"type": field_info.get("type", "string"),
|
||||
"type": resolved.get("type", "string"),
|
||||
}
|
||||
# Pass through items info (including enum values) for array fields
|
||||
# so the UI can render a multi-select dropdown
|
||||
if "items" in resolved:
|
||||
items = resolved["items"]
|
||||
# Resolve $ref to enum definitions if needed
|
||||
if "$ref" in items:
|
||||
ref_name = items["$ref"].split("/")[-1]
|
||||
ref_def = defs.get(ref_name, {})
|
||||
if "enum" in ref_def:
|
||||
prop_entry["items"] = {"enum": ref_def["enum"]}
|
||||
else:
|
||||
prop_entry["items"] = items
|
||||
result["field_schema"]["properties"][field_name] = prop_entry
|
||||
|
||||
# Add nested object descriptions
|
||||
for def_name, def_schema in schema.get("definitions", {}).items():
|
||||
@@ -427,7 +450,6 @@ async def _update_litellm_setting(
|
||||
DefaultInternalUserParams, DefaultTeamSSOParams, MCPSemanticFilterSettings
|
||||
],
|
||||
settings_key: str,
|
||||
in_memory_var: Any,
|
||||
success_message: str,
|
||||
):
|
||||
"""
|
||||
@@ -436,7 +458,6 @@ async def _update_litellm_setting(
|
||||
Args:
|
||||
settings: The settings object to update
|
||||
settings_key: The key in litellm_settings to update
|
||||
in_memory_var: The in-memory variable to update
|
||||
success_message: Message to return on success
|
||||
"""
|
||||
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
|
||||
@@ -449,13 +470,16 @@ async def _update_litellm_setting(
|
||||
},
|
||||
)
|
||||
|
||||
# Update the in-memory settings
|
||||
in_memory_var = settings.model_dump(exclude_none=True)
|
||||
setattr(litellm, settings_key, in_memory_var)
|
||||
|
||||
# Load existing config
|
||||
# Load existing config first, then set in-memory value after,
|
||||
# because get_config() may overwrite litellm.<key> with stale DB values
|
||||
# via LITELLM_SETTINGS_SAFE_DB_OVERRIDES.
|
||||
config = await proxy_config.get_config()
|
||||
|
||||
# Update the in-memory settings (after get_config to avoid stale override)
|
||||
setattr(litellm, settings_key, in_memory_var)
|
||||
|
||||
# Update config with new settings
|
||||
if "litellm_settings" not in config:
|
||||
config["litellm_settings"] = {}
|
||||
@@ -495,7 +519,6 @@ async def update_internal_user_settings(
|
||||
return await _update_litellm_setting(
|
||||
settings=settings,
|
||||
settings_key="default_internal_user_params",
|
||||
in_memory_var=litellm.default_internal_user_params,
|
||||
success_message="Internal user settings updated successfully",
|
||||
)
|
||||
|
||||
@@ -513,7 +536,6 @@ async def update_default_team_settings(settings: DefaultTeamSSOParams):
|
||||
return await _update_litellm_setting(
|
||||
settings=settings,
|
||||
settings_key="default_team_params",
|
||||
in_memory_var=litellm.default_team_params,
|
||||
success_message="Default team settings updated successfully",
|
||||
)
|
||||
|
||||
@@ -935,7 +957,6 @@ async def update_mcp_semantic_filter_settings(
|
||||
result = await _update_litellm_setting(
|
||||
settings=settings,
|
||||
settings_key="mcp_semantic_tool_filter",
|
||||
in_memory_var=None,
|
||||
success_message="MCP Semantic Filter settings updated successfully. Changes will be applied across all pods within 10 seconds.",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Dict, List, Literal, Optional, Union
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy._types import KeyManagementRoutes, LitellmUserRoles
|
||||
from litellm.types.utils import LiteLLMPydanticObjectBase
|
||||
|
||||
|
||||
@@ -205,6 +205,10 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
|
||||
default=None,
|
||||
description="Default rpm limit for new automatically created teams",
|
||||
)
|
||||
team_member_permissions: Optional[List[KeyManagementRoutes]] = Field(
|
||||
default=None,
|
||||
description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.",
|
||||
)
|
||||
|
||||
|
||||
class InProductNudgeResponse(BaseModel):
|
||||
|
||||
@@ -202,7 +202,6 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp
|
||||
await _update_litellm_setting(
|
||||
settings=settings,
|
||||
settings_key="default_internal_user_params",
|
||||
in_memory_var=litellm.default_internal_user_params,
|
||||
success_message="ok",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Tests for applying default team params during team creation
|
||||
and loading default_team_params from DB on startup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import (
|
||||
NewTeamRequest,
|
||||
UserAPIKeyAuth,
|
||||
LitellmUserRoles,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _update_config_fields: default_team_params loaded from DB on startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigFieldsDefaultTeamParams:
|
||||
"""Tests that _update_config_fields applies default_team_params from DB."""
|
||||
|
||||
def _make_proxy_config(self) -> ProxyConfig:
|
||||
return ProxyConfig()
|
||||
|
||||
def test_default_team_params_applied_from_db(self, monkeypatch):
|
||||
"""default_team_params in DB is set on litellm module during config load."""
|
||||
monkeypatch.setattr(litellm, "default_team_params", None)
|
||||
|
||||
pc = self._make_proxy_config()
|
||||
db_settings = {
|
||||
"default_team_params": {
|
||||
"max_budget": 500.0,
|
||||
"budget_duration": "30d",
|
||||
"tpm_limit": 1000,
|
||||
"rpm_limit": 200,
|
||||
"team_member_permissions": ["/key/generate", "/key/delete"],
|
||||
}
|
||||
}
|
||||
|
||||
pc._update_config_fields(
|
||||
current_config={},
|
||||
param_name="litellm_settings",
|
||||
db_param_value=db_settings,
|
||||
)
|
||||
|
||||
assert litellm.default_team_params == db_settings["default_team_params"]
|
||||
|
||||
def test_default_team_params_merged_into_config_dict(self):
|
||||
"""DB default_team_params ends up in the returned config dict."""
|
||||
pc = self._make_proxy_config()
|
||||
config = {"litellm_settings": {"cache": False}}
|
||||
db_settings = {
|
||||
"default_team_params": {
|
||||
"max_budget": 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
result = pc._update_config_fields(
|
||||
current_config=config,
|
||||
param_name="litellm_settings",
|
||||
db_param_value=db_settings,
|
||||
)
|
||||
|
||||
assert result["litellm_settings"]["default_team_params"] == {"max_budget": 100.0}
|
||||
# Existing keys preserved
|
||||
assert result["litellm_settings"]["cache"] is False
|
||||
|
||||
def test_default_team_params_not_applied_when_absent(self, monkeypatch):
|
||||
"""When DB litellm_settings has no default_team_params, it stays None."""
|
||||
monkeypatch.setattr(litellm, "default_team_params", None)
|
||||
|
||||
pc = self._make_proxy_config()
|
||||
pc._update_config_fields(
|
||||
current_config={},
|
||||
param_name="litellm_settings",
|
||||
db_param_value={"cache": True},
|
||||
)
|
||||
|
||||
assert litellm.default_team_params is None
|
||||
|
||||
def test_default_team_params_overrides_yaml_value(self, monkeypatch):
|
||||
"""DB value for default_team_params overrides YAML value via deep merge."""
|
||||
monkeypatch.setattr(litellm, "default_team_params", None)
|
||||
|
||||
pc = self._make_proxy_config()
|
||||
config = {
|
||||
"litellm_settings": {
|
||||
"default_team_params": {
|
||||
"max_budget": 50.0,
|
||||
"tpm_limit": 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
db_settings = {
|
||||
"default_team_params": {
|
||||
"max_budget": 200.0,
|
||||
"rpm_limit": 500,
|
||||
}
|
||||
}
|
||||
|
||||
result = pc._update_config_fields(
|
||||
current_config=config,
|
||||
param_name="litellm_settings",
|
||||
db_param_value=db_settings,
|
||||
)
|
||||
|
||||
merged = result["litellm_settings"]["default_team_params"]
|
||||
# DB value wins for max_budget
|
||||
assert merged["max_budget"] == 200.0
|
||||
# DB adds rpm_limit
|
||||
assert merged["rpm_limit"] == 500
|
||||
# YAML tpm_limit preserved (not in DB)
|
||||
assert merged["tpm_limit"] == 100
|
||||
|
||||
# setattr should have applied the DB value
|
||||
assert litellm.default_team_params == db_settings["default_team_params"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_team: default params applied to team creation
|
||||
#
|
||||
# We test the defaults-application logic by calling new_team with
|
||||
# prisma_client patched at the proxy_server module level (where the
|
||||
# endpoint imports it from).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNewTeamDefaultParamsApplied:
|
||||
"""Tests that /team/new applies defaults from litellm.default_team_params."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_mocks(self, monkeypatch):
|
||||
"""Set up common mocks for team creation tests."""
|
||||
mock_prisma = AsyncMock()
|
||||
mock_prisma.insert_data = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
team_id="test-team-id",
|
||||
team_alias="test-team",
|
||||
)
|
||||
)
|
||||
mock_prisma.get_generic_data = AsyncMock(return_value=None)
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client", mock_prisma
|
||||
)
|
||||
|
||||
# Reset default_team_settings to avoid legacy fallback interference
|
||||
monkeypatch.setattr(litellm, "default_team_settings", None)
|
||||
|
||||
def _make_admin_auth(self) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
user_id="admin-user",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_defaults_applied_when_not_provided(self, monkeypatch):
|
||||
"""When no budget/rate/permission fields are in the request, all defaults apply."""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_params",
|
||||
{
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "30d",
|
||||
"tpm_limit": 200,
|
||||
"rpm_limit": 500,
|
||||
"team_member_permissions": ["/key/generate", "/key/update"],
|
||||
},
|
||||
)
|
||||
|
||||
data = NewTeamRequest(team_alias="my-team")
|
||||
auth = self._make_admin_auth()
|
||||
|
||||
try:
|
||||
await new_team(
|
||||
data=data,
|
||||
user_api_key_dict=auth,
|
||||
http_request=MagicMock(),
|
||||
)
|
||||
except Exception:
|
||||
pass # May fail on downstream mocks, that's OK
|
||||
|
||||
# Verify defaults were set on the data object
|
||||
assert data.max_budget == 100.0
|
||||
assert data.budget_duration == "30d"
|
||||
assert data.tpm_limit == 200
|
||||
assert data.rpm_limit == 500
|
||||
assert data.team_member_permissions == ["/key/generate", "/key/update"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_values_not_overridden(self, monkeypatch):
|
||||
"""When request provides explicit values, defaults do not override them."""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_params",
|
||||
{
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "30d",
|
||||
"tpm_limit": 200,
|
||||
"rpm_limit": 500,
|
||||
"team_member_permissions": ["/key/generate"],
|
||||
},
|
||||
)
|
||||
|
||||
data = NewTeamRequest(
|
||||
team_alias="my-team",
|
||||
max_budget=50.0,
|
||||
budget_duration="7d",
|
||||
tpm_limit=999,
|
||||
rpm_limit=888,
|
||||
team_member_permissions=["/key/delete"],
|
||||
)
|
||||
auth = self._make_admin_auth()
|
||||
|
||||
try:
|
||||
await new_team(
|
||||
data=data,
|
||||
user_api_key_dict=auth,
|
||||
http_request=MagicMock(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Explicit values preserved
|
||||
assert data.max_budget == 50.0
|
||||
assert data.budget_duration == "7d"
|
||||
assert data.tpm_limit == 999
|
||||
assert data.rpm_limit == 888
|
||||
assert data.team_member_permissions == ["/key/delete"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_defaults_applied(self, monkeypatch):
|
||||
"""Only missing fields get defaults; provided fields are untouched."""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_params",
|
||||
{
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "30d",
|
||||
"tpm_limit": 200,
|
||||
"rpm_limit": 500,
|
||||
},
|
||||
)
|
||||
|
||||
data = NewTeamRequest(
|
||||
team_alias="my-team",
|
||||
max_budget=75.0, # explicit
|
||||
# budget_duration, tpm_limit, rpm_limit not set → defaults apply
|
||||
)
|
||||
auth = self._make_admin_auth()
|
||||
|
||||
try:
|
||||
await new_team(
|
||||
data=data,
|
||||
user_api_key_dict=auth,
|
||||
http_request=MagicMock(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert data.max_budget == 75.0 # explicit, not overridden
|
||||
assert data.budget_duration == "30d" # default applied
|
||||
assert data.tpm_limit == 200 # default applied
|
||||
assert data.rpm_limit == 500 # default applied
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_defaults_when_config_is_none(self, monkeypatch):
|
||||
"""When default_team_params is None, no defaults applied."""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
monkeypatch.setattr(litellm, "default_team_params", None)
|
||||
|
||||
data = NewTeamRequest(team_alias="my-team")
|
||||
auth = self._make_admin_auth()
|
||||
|
||||
try:
|
||||
await new_team(
|
||||
data=data,
|
||||
user_api_key_dict=auth,
|
||||
http_request=MagicMock(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert data.max_budget is None
|
||||
assert data.budget_duration is None
|
||||
assert data.tpm_limit is None
|
||||
assert data.rpm_limit is None
|
||||
assert data.team_member_permissions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_default_team_settings_fallback(self, monkeypatch):
|
||||
"""Legacy default_team_settings YAML config applies max_budget as fallback."""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
monkeypatch.setattr(litellm, "default_team_params", None)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_settings",
|
||||
[{"team_id": "default", "max_budget": 999.0}],
|
||||
)
|
||||
|
||||
data = NewTeamRequest(team_alias="my-team")
|
||||
auth = self._make_admin_auth()
|
||||
|
||||
try:
|
||||
await new_team(
|
||||
data=data,
|
||||
user_api_key_dict=auth,
|
||||
http_request=MagicMock(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert data.max_budget == 999.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_team_params_takes_priority_over_legacy(self, monkeypatch):
|
||||
"""default_team_params max_budget takes priority over legacy default_team_settings."""
|
||||
from litellm.proxy.management_endpoints.team_endpoints import new_team
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_params",
|
||||
{"max_budget": 100.0},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_settings",
|
||||
[{"team_id": "default", "max_budget": 999.0}],
|
||||
)
|
||||
|
||||
data = NewTeamRequest(team_alias="my-team")
|
||||
auth = self._make_admin_auth()
|
||||
|
||||
try:
|
||||
await new_team(
|
||||
data=data,
|
||||
user_api_key_dict=auth,
|
||||
http_request=MagicMock(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# default_team_params wins (100.0), legacy fallback (999.0) not used
|
||||
assert data.max_budget == 100.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _update_litellm_setting: setattr ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateLitellmSettingOrdering:
|
||||
"""Tests that _update_litellm_setting sets in-memory value AFTER get_config,
|
||||
so stale DB values from LITELLM_SETTINGS_SAFE_DB_OVERRIDES don't overwrite it."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setattr_not_overwritten_by_get_config(self, monkeypatch):
|
||||
"""The new in-memory value survives get_config() which may load stale DB values."""
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
|
||||
_update_litellm_setting,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import (
|
||||
DefaultTeamSSOParams,
|
||||
)
|
||||
|
||||
# Simulate stale DB state: get_config returns old default_team_params
|
||||
stale_value = {"max_budget": 50.0}
|
||||
monkeypatch.setattr(litellm, "default_team_params", stale_value)
|
||||
|
||||
# get_config will overwrite litellm.default_team_params with stale DB value
|
||||
async def mock_get_config():
|
||||
# Simulate what _update_config_from_db does for safe overrides
|
||||
litellm.default_team_params = stale_value
|
||||
return {
|
||||
"litellm_settings": {
|
||||
"default_team_params": stale_value,
|
||||
}
|
||||
}
|
||||
|
||||
saved_configs = []
|
||||
|
||||
async def mock_save_config(new_config=None):
|
||||
saved_configs.append(new_config)
|
||||
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
|
||||
monkeypatch.setattr(proxy_config, "save_config", mock_save_config)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.store_model_in_db", True
|
||||
)
|
||||
|
||||
# New settings to save
|
||||
new_settings = DefaultTeamSSOParams(
|
||||
max_budget=200.0,
|
||||
budget_duration="7d",
|
||||
rpm_limit=1000,
|
||||
)
|
||||
|
||||
result = await _update_litellm_setting(
|
||||
settings=new_settings,
|
||||
settings_key="default_team_params",
|
||||
success_message="Updated",
|
||||
)
|
||||
|
||||
# In-memory value should be the NEW value, not the stale one
|
||||
expected = new_settings.model_dump(exclude_none=True)
|
||||
assert litellm.default_team_params == expected
|
||||
|
||||
# Saved config should contain the new value
|
||||
assert len(saved_configs) == 1
|
||||
saved_settings = saved_configs[0]["litellm_settings"]["default_team_params"]
|
||||
assert saved_settings == expected
|
||||
|
||||
# Return value should reflect the new settings
|
||||
assert result["settings"] == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_store_model_in_db(self, monkeypatch):
|
||||
"""Raises HTTPException when store_model_in_db is not True."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
|
||||
_update_litellm_setting,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import (
|
||||
DefaultTeamSSOParams,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.store_model_in_db", False
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _update_litellm_setting(
|
||||
settings=DefaultTeamSSOParams(max_budget=100.0),
|
||||
settings_key="default_team_params",
|
||||
success_message="Updated",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LITELLM_SETTINGS_SAFE_DB_OVERRIDES contains default_team_params
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeDbOverrides:
|
||||
"""Verify default_team_params is in the safe overrides list."""
|
||||
|
||||
def test_default_team_params_in_safe_overrides(self):
|
||||
from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES
|
||||
|
||||
assert "default_team_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES
|
||||
|
||||
def test_default_internal_user_params_in_safe_overrides(self):
|
||||
"""Sanity: default_internal_user_params was already in the list."""
|
||||
from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES
|
||||
|
||||
assert "default_internal_user_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES
|
||||
@@ -0,0 +1,190 @@
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.proxy._types import KeyManagementRoutes, Member
|
||||
from litellm.proxy.management_helpers.team_member_permission_checks import (
|
||||
BASELINE_TEAM_MEMBER_PERMISSIONS,
|
||||
TeamMemberPermissionChecks,
|
||||
)
|
||||
|
||||
|
||||
def _make_team_table(team_member_permissions):
|
||||
"""Create a mock team table object with given permissions."""
|
||||
team = MagicMock()
|
||||
team.team_member_permissions = team_member_permissions
|
||||
return team
|
||||
|
||||
|
||||
class TestGetPermissionsForTeamMember:
|
||||
def test_none_permissions_returns_defaults(self):
|
||||
"""When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS."""
|
||||
team = _make_team_table(None)
|
||||
member = MagicMock(spec=Member)
|
||||
|
||||
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
|
||||
team_member_object=member, team_table=team
|
||||
)
|
||||
|
||||
assert set(result) == set(BASELINE_TEAM_MEMBER_PERMISSIONS)
|
||||
|
||||
def test_empty_list_includes_baseline(self):
|
||||
"""When team_member_permissions is [], baseline permissions are still included."""
|
||||
team = _make_team_table([])
|
||||
member = MagicMock(spec=Member)
|
||||
|
||||
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
|
||||
team_member_object=member, team_table=team
|
||||
)
|
||||
|
||||
assert KeyManagementRoutes.KEY_INFO in result
|
||||
assert KeyManagementRoutes.KEY_HEALTH in result
|
||||
|
||||
def test_explicit_permissions_include_baseline(self):
|
||||
"""When explicit permissions are set, baseline is always included."""
|
||||
team = _make_team_table(["/key/generate", "/key/delete"])
|
||||
member = MagicMock(spec=Member)
|
||||
|
||||
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
|
||||
team_member_object=member, team_table=team
|
||||
)
|
||||
|
||||
assert KeyManagementRoutes.KEY_GENERATE in result
|
||||
assert KeyManagementRoutes.KEY_DELETE in result
|
||||
assert KeyManagementRoutes.KEY_INFO in result
|
||||
assert KeyManagementRoutes.KEY_HEALTH in result
|
||||
|
||||
def test_explicit_permissions_with_baseline_no_duplicates(self):
|
||||
"""When explicit permissions already include baseline, no duplicates."""
|
||||
team = _make_team_table(["/key/info", "/key/generate"])
|
||||
member = MagicMock(spec=Member)
|
||||
|
||||
result = TeamMemberPermissionChecks.get_permissions_for_team_member(
|
||||
team_member_object=member, team_table=team
|
||||
)
|
||||
|
||||
# Using set ensures no duplicates from the implementation
|
||||
assert KeyManagementRoutes.KEY_INFO in result
|
||||
assert KeyManagementRoutes.KEY_GENERATE in result
|
||||
assert KeyManagementRoutes.KEY_HEALTH in result
|
||||
|
||||
|
||||
class TestGetDefaultTeamParam:
|
||||
def test_returns_none_when_no_config(self, monkeypatch):
|
||||
"""Returns None when litellm.default_team_params is None."""
|
||||
import litellm
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(litellm, "default_team_params", None)
|
||||
|
||||
assert _get_default_team_param("team_member_permissions") is None
|
||||
assert _get_default_team_param("max_budget") is None
|
||||
|
||||
def test_returns_none_when_field_not_set(self, monkeypatch):
|
||||
"""Returns None when default_team_params exists but the field is not set."""
|
||||
import litellm
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(litellm, "default_team_params", {"models": ["gpt-4"]})
|
||||
|
||||
assert _get_default_team_param("team_member_permissions") is None
|
||||
assert _get_default_team_param("max_budget") is None
|
||||
|
||||
def test_returns_permissions_from_dict_config(self, monkeypatch):
|
||||
"""Returns permissions when default_team_params is a dict."""
|
||||
import litellm
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_params",
|
||||
{"team_member_permissions": ["/key/generate", "/key/update"]},
|
||||
)
|
||||
|
||||
result = _get_default_team_param("team_member_permissions")
|
||||
assert result == ["/key/generate", "/key/update"]
|
||||
|
||||
def test_returns_scalar_fields_from_dict_config(self, monkeypatch):
|
||||
"""Returns scalar fields (max_budget, tpm_limit, etc.) from dict config."""
|
||||
import litellm
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"default_team_params",
|
||||
{
|
||||
"max_budget": 100.0,
|
||||
"budget_duration": "30d",
|
||||
"tpm_limit": 200,
|
||||
"rpm_limit": 500,
|
||||
},
|
||||
)
|
||||
|
||||
assert _get_default_team_param("max_budget") == 100.0
|
||||
assert _get_default_team_param("budget_duration") == "30d"
|
||||
assert _get_default_team_param("tpm_limit") == 200
|
||||
assert _get_default_team_param("rpm_limit") == 500
|
||||
|
||||
def test_returns_permissions_from_pydantic_config(self, monkeypatch):
|
||||
"""Returns permissions when default_team_params is a DefaultTeamSSOParams object."""
|
||||
import litellm
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import (
|
||||
DefaultTeamSSOParams,
|
||||
)
|
||||
|
||||
params = DefaultTeamSSOParams(
|
||||
team_member_permissions=[
|
||||
KeyManagementRoutes.KEY_GENERATE,
|
||||
KeyManagementRoutes.KEY_DELETE,
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(litellm, "default_team_params", params)
|
||||
|
||||
result = _get_default_team_param("team_member_permissions")
|
||||
assert result == ["/key/generate", "/key/delete"]
|
||||
|
||||
def test_returns_scalar_fields_from_pydantic_config(self, monkeypatch):
|
||||
"""Returns scalar fields from DefaultTeamSSOParams object."""
|
||||
import litellm
|
||||
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
_get_default_team_param,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import (
|
||||
DefaultTeamSSOParams,
|
||||
)
|
||||
|
||||
params = DefaultTeamSSOParams(
|
||||
max_budget=250.0,
|
||||
budget_duration="7d",
|
||||
tpm_limit=1000,
|
||||
rpm_limit=100,
|
||||
)
|
||||
monkeypatch.setattr(litellm, "default_team_params", params)
|
||||
|
||||
assert _get_default_team_param("max_budget") == 250.0
|
||||
assert _get_default_team_param("budget_duration") == "7d"
|
||||
assert _get_default_team_param("tpm_limit") == 1000
|
||||
assert _get_default_team_param("rpm_limit") == 100
|
||||
@@ -231,6 +231,56 @@ class TestProxySettingEndpoints:
|
||||
# Verify save_config was called exactly once
|
||||
assert mock_proxy_config["save_call_count"]() == 1
|
||||
|
||||
def test_get_default_team_settings_includes_team_member_permissions_schema(
|
||||
self, mock_proxy_config, mock_auth
|
||||
):
|
||||
"""Test that team_member_permissions field appears in schema with enum items"""
|
||||
response = client.get("/get/default_team_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that team_member_permissions is in the schema
|
||||
props = data["field_schema"]["properties"]
|
||||
assert "team_member_permissions" in props
|
||||
|
||||
perm_schema = props["team_member_permissions"]
|
||||
assert perm_schema["type"] == "array"
|
||||
assert "items" in perm_schema
|
||||
assert "enum" in perm_schema["items"]
|
||||
# Verify some known enum values are present
|
||||
enum_values = perm_schema["items"]["enum"]
|
||||
assert "/key/generate" in enum_values
|
||||
assert "/key/info" in enum_values
|
||||
assert "/key/delete" in enum_values
|
||||
|
||||
def test_update_default_team_settings_with_permissions(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""Test updating default team settings with team_member_permissions"""
|
||||
import litellm
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
monkeypatch.setattr(litellm, "default_team_params", {})
|
||||
|
||||
new_settings = {
|
||||
"models": ["gpt-4"],
|
||||
"team_member_permissions": ["/key/generate", "/key/update", "/key/delete"],
|
||||
}
|
||||
|
||||
response = client.patch("/update/default_team_settings", json=new_settings)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
settings = data["settings"]
|
||||
assert settings["team_member_permissions"] == [
|
||||
"/key/generate",
|
||||
"/key/update",
|
||||
"/key/delete",
|
||||
]
|
||||
|
||||
def test_get_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
|
||||
"""Test getting the SSO settings from the dedicated database table"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -18,14 +18,18 @@ vi.mock("./common_components/budget_duration_dropdown", () => {
|
||||
aria-label="Budget duration"
|
||||
>
|
||||
<option value="">Select duration</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="24h">Daily</option>
|
||||
<option value="7d">Weekly</option>
|
||||
<option value="30d">Monthly</option>
|
||||
</select>
|
||||
);
|
||||
BudgetDurationDropdown.displayName = "BudgetDurationDropdown";
|
||||
return {
|
||||
default: BudgetDurationDropdown,
|
||||
getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`),
|
||||
getBudgetDurationLabel: vi.fn((value: string) => {
|
||||
const map: Record<string, string> = { "24h": "daily", "7d": "weekly", "30d": "monthly" };
|
||||
return map[value] || value;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -56,6 +60,7 @@ vi.mock("./ModelSelect/ModelSelect", () => {
|
||||
vi.mock("antd", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("antd")>();
|
||||
const React = await import("react");
|
||||
|
||||
const SelectComponent = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -88,37 +93,53 @@ vi.mock("antd", async (importOriginal) => {
|
||||
);
|
||||
};
|
||||
SelectComponent.displayName = "Select";
|
||||
|
||||
const SelectOption = ({ value: optionValue, children: optionChildren }: { value: string; children: React.ReactNode }) =>
|
||||
React.createElement("option", { value: optionValue }, optionChildren);
|
||||
SelectOption.displayName = "SelectOption";
|
||||
SelectComponent.Option = SelectOption;
|
||||
const Spin = ({ size }: { size?: string }) => React.createElement("div", { "data-testid": "spinner", "data-size": size });
|
||||
|
||||
const Spin = ({ size }: { size?: string }) =>
|
||||
React.createElement("div", { "data-testid": "spinner", "data-size": size });
|
||||
Spin.displayName = "Spin";
|
||||
const Switch = ({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) =>
|
||||
|
||||
const InputNumber = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
prefix,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
placeholder?: string;
|
||||
prefix?: string;
|
||||
min?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
type: "checkbox",
|
||||
role: "switch",
|
||||
checked: checked,
|
||||
onChange: (e) => onChange(e.target.checked),
|
||||
"aria-label": "Toggle switch",
|
||||
type: "number",
|
||||
value: value ?? "",
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value === "" ? null : Number(e.target.value);
|
||||
onChange(v);
|
||||
},
|
||||
placeholder,
|
||||
"data-prefix": prefix,
|
||||
"aria-label": "number input",
|
||||
});
|
||||
Switch.displayName = "Switch";
|
||||
const Paragraph = ({ children }: { children: React.ReactNode }) => React.createElement("p", {}, children);
|
||||
Paragraph.displayName = "Paragraph";
|
||||
InputNumber.displayName = "InputNumber";
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Spin,
|
||||
Switch,
|
||||
Select: SelectComponent,
|
||||
Typography: {
|
||||
Paragraph,
|
||||
},
|
||||
InputNumber,
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings);
|
||||
const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings);
|
||||
const mockModelAvailableCall = vi.mocked(networking.modelAvailableCall);
|
||||
const mockNotificationsManager = vi.mocked(NotificationsManager);
|
||||
|
||||
describe("TeamSSOSettings", () => {
|
||||
@@ -128,77 +149,33 @@ describe("TeamSSOSettings", () => {
|
||||
userRole: "admin",
|
||||
};
|
||||
|
||||
const mockSettings = {
|
||||
const mockSettingsResponse = {
|
||||
values: {
|
||||
budget_duration: "monthly",
|
||||
max_budget: 1000,
|
||||
enabled: true,
|
||||
allowed_models: ["gpt-4", "claude-3"],
|
||||
budget_duration: "30d",
|
||||
tpm_limit: 500,
|
||||
rpm_limit: 100,
|
||||
models: ["gpt-4"],
|
||||
status: "active",
|
||||
},
|
||||
field_schema: {
|
||||
description: "Default team settings schema",
|
||||
properties: {
|
||||
budget_duration: {
|
||||
type: "string",
|
||||
description: "Budget duration setting",
|
||||
},
|
||||
max_budget: {
|
||||
type: "number",
|
||||
description: "Maximum budget amount",
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
description: "Enable feature",
|
||||
},
|
||||
allowed_models: {
|
||||
type: "array",
|
||||
items: {
|
||||
enum: ["gpt-4", "claude-3", "gpt-3.5-turbo"],
|
||||
},
|
||||
description: "Allowed models",
|
||||
},
|
||||
models: {
|
||||
type: "array",
|
||||
description: "Selected models",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["active", "inactive", "pending"],
|
||||
description: "Status",
|
||||
},
|
||||
},
|
||||
team_member_permissions: ["/key/generate", "/key/update"],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockModelAvailableCall.mockResolvedValue({
|
||||
data: [{ id: "gpt-4" }, { id: "claude-3" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
// --- Loading & Error States ---
|
||||
|
||||
it("should show loading spinner while fetching settings", () => {
|
||||
mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => { }));
|
||||
mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display message when no settings are available", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(null as any);
|
||||
it("should display error message when fetch fails", async () => {
|
||||
mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed"));
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
@@ -207,6 +184,7 @@ describe("TeamSSOSettings", () => {
|
||||
screen.getByText("No team settings available or you do not have permission to view them."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings");
|
||||
});
|
||||
|
||||
it("should not fetch settings when access token is null", async () => {
|
||||
@@ -217,432 +195,273 @@ describe("TeamSSOSettings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should display settings fields with correct values", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
// --- View Mode ---
|
||||
|
||||
it("should render title and subtitle", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("These settings will be applied by default when creating new teams.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render section headers", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Budget & Rate Limits")).toBeInTheDocument();
|
||||
expect(screen.getByText("Access & Permissions")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display all field labels and descriptions", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Budget Duration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max Budget")).toBeInTheDocument();
|
||||
expect(screen.getByText("Budget Duration")).toBeInTheDocument();
|
||||
expect(screen.getByText("TPM Limit")).toBeInTheDocument();
|
||||
expect(screen.getByText("RPM Limit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Member Permissions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Budget: monthly")).toBeInTheDocument();
|
||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
||||
const enabledTexts = screen.getAllByText("Enabled");
|
||||
expect(enabledTexts.length).toBeGreaterThan(0);
|
||||
// Descriptions
|
||||
expect(screen.getByText("Maximum budget (in USD) for new automatically created teams.")).toBeInTheDocument();
|
||||
expect(screen.getByText("How frequently the team's budget resets.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display formatted values in view mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// max_budget displayed with $
|
||||
expect(screen.getByText("$1,000")).toBeInTheDocument();
|
||||
// budget_duration through getBudgetDurationLabel
|
||||
expect(screen.getByText("monthly")).toBeInTheDocument();
|
||||
// tpm_limit formatted
|
||||
expect(screen.getByText("500")).toBeInTheDocument();
|
||||
// rpm_limit formatted
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display models as tags in view mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display permissions as tags in view mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("/key/generate")).toBeInTheDocument();
|
||||
expect(screen.getByText("/key/update")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display 'Not set' for null values", async () => {
|
||||
const settingsWithNulls = {
|
||||
...mockSettings,
|
||||
mockGetDefaultTeamSettings.mockResolvedValue({
|
||||
values: {
|
||||
...mockSettings.values,
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
models: [],
|
||||
team_member_permissions: [],
|
||||
},
|
||||
};
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithNulls);
|
||||
});
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument();
|
||||
const notSetElements = screen.getAllByText("Not set");
|
||||
// max_budget, budget_duration, tpm_limit, rpm_limit, models (empty), permissions (empty)
|
||||
expect(notSetElements.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle edit mode when edit button is clicked", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
// --- Edit Mode Toggle ---
|
||||
|
||||
it("should toggle to edit mode when Edit Settings is clicked", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Save Changes/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Edit Settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should cancel edit mode and reset values", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Cancel/i }));
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Cancel/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should save settings when save button is clicked", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
mockUpdateDefaultTeamSettings.mockResolvedValue({
|
||||
settings: mockSettings.values,
|
||||
});
|
||||
// --- Edit Mode Fields ---
|
||||
|
||||
it("should show budget duration dropdown in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Changes" });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", mockSettings.values);
|
||||
});
|
||||
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully");
|
||||
});
|
||||
|
||||
it("should show error notification when save fails", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed"));
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Changes" });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings");
|
||||
expect(screen.getByTestId("budget-duration-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render boolean field as switch in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
it("should show ModelSelect in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).toBeInTheDocument();
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should update boolean value when switch is toggled", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchElement = screen.getByRole("switch");
|
||||
await userEvent.click(switchElement);
|
||||
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should render budget duration dropdown in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Budget duration")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should update budget duration when dropdown value changes", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Budget duration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dropdown = screen.getByLabelText("Budget duration");
|
||||
await userEvent.selectOptions(dropdown, "daily");
|
||||
|
||||
expect(dropdown).toHaveValue("daily");
|
||||
});
|
||||
|
||||
it("should render text input for string fields in edit mode", async () => {
|
||||
const settingsWithString = {
|
||||
...mockSettings,
|
||||
field_schema: {
|
||||
...mockSettings.field_schema,
|
||||
properties: {
|
||||
...mockSettings.field_schema.properties,
|
||||
team_name: {
|
||||
type: "string",
|
||||
description: "Team name",
|
||||
},
|
||||
},
|
||||
},
|
||||
values: {
|
||||
...mockSettings.values,
|
||||
team_name: "Test Team",
|
||||
},
|
||||
};
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithString);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const textInput = screen.getByDisplayValue("Test Team");
|
||||
expect(textInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render enum select for string enum fields in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const statusSelect = screen.getAllByRole("listbox")[0];
|
||||
expect(statusSelect).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render multi-select for array enum fields in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const multiSelects = screen.getAllByRole("listbox");
|
||||
expect(multiSelects.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render ModelSelect for models field in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("model-select")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display models as badges in view mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
it("should show number inputs for budget and rate limits in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const gpt4Elements = screen.getAllByText("gpt-4");
|
||||
expect(gpt4Elements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const numberInputs = screen.getAllByLabelText("number input");
|
||||
// max_budget, tpm_limit, rpm_limit
|
||||
expect(numberInputs.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display 'None' for empty arrays in view mode", async () => {
|
||||
const settingsWithEmptyArray = {
|
||||
...mockSettings,
|
||||
values: {
|
||||
...mockSettings.values,
|
||||
models: [],
|
||||
},
|
||||
};
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithEmptyArray);
|
||||
it("should show permissions multi-select in edit mode", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const noneTexts = screen.getAllByText("None");
|
||||
expect(noneTexts.length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const listboxes = screen.getAllByRole("listbox");
|
||||
expect(listboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display schema description when available", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
// --- Save ---
|
||||
|
||||
it("should save settings and show success notification", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
mockUpdateDefaultTeamSettings.mockResolvedValue({
|
||||
settings: mockSettingsResponse.values,
|
||||
});
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default team settings schema")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", expect.any(Object));
|
||||
});
|
||||
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully");
|
||||
|
||||
// Should exit edit mode after save
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error notification when fetching settings fails", async () => {
|
||||
mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed"));
|
||||
it("should show error notification when save fails", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed"));
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings");
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle model fetch error gracefully", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
mockModelAvailableCall.mockRejectedValue(new Error("Model fetch failed"));
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings");
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable cancel button while saving", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse);
|
||||
mockUpdateDefaultTeamSettings.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettings.values }), 100)),
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettingsResponse.values }), 100)),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByRole("button", { name: "Edit Settings" });
|
||||
await userEvent.click(editButton);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save Changes" });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should display field descriptions", async () => {
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Budget duration setting")).toBeInTheDocument();
|
||||
expect(screen.getByText("Maximum budget amount")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should format field names by replacing underscores and capitalizing", async () => {
|
||||
const settingsWithUnderscores = {
|
||||
...mockSettings,
|
||||
field_schema: {
|
||||
...mockSettings.field_schema,
|
||||
properties: {
|
||||
...mockSettings.field_schema.properties,
|
||||
max_budget_per_user: {
|
||||
type: "number",
|
||||
description: "Max budget per user",
|
||||
},
|
||||
},
|
||||
},
|
||||
values: {
|
||||
...mockSettings.values,
|
||||
max_budget_per_user: 500,
|
||||
},
|
||||
};
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithUnderscores);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Max Budget Per User")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display 'No schema information available' when schema is missing", async () => {
|
||||
const settingsWithoutSchema = {
|
||||
values: {},
|
||||
field_schema: null,
|
||||
};
|
||||
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithoutSchema);
|
||||
|
||||
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No schema information available")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,96 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react";
|
||||
import { Typography, Spin, Switch, Select } from "antd";
|
||||
import { getDefaultTeamSettings, updateDefaultTeamSettings, modelAvailableCall } from "./networking";
|
||||
import { Card, Button, InputNumber, Typography, Spin, Select, Tag, Row, Col } from "antd";
|
||||
import { EditOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { getDefaultTeamSettings, updateDefaultTeamSettings } from "./networking";
|
||||
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
|
||||
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { ModelSelect } from "./ModelSelect/ModelSelect";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface TeamSSOSettingsProps {
|
||||
accessToken: string | null;
|
||||
userID: string;
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID, userRole }) => {
|
||||
const PERMISSION_OPTIONS = [
|
||||
"/key/generate",
|
||||
"/key/update",
|
||||
"/key/delete",
|
||||
"/key/regenerate",
|
||||
"/key/service-account/generate",
|
||||
"/key/{key_id}/regenerate",
|
||||
"/key/block",
|
||||
"/key/unblock",
|
||||
"/key/bulk_update",
|
||||
"/key/{key_id}/reset_spend",
|
||||
];
|
||||
|
||||
interface SettingRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
isEditing: boolean;
|
||||
viewContent: React.ReactNode;
|
||||
editContent: React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingRow: React.FC<SettingRowProps> = ({ label, description, isEditing, viewContent, editContent }) => (
|
||||
<Row className="py-5 border-b border-gray-100 last:border-0">
|
||||
<Col span={8} className="pr-6">
|
||||
<div className="text-sm font-semibold text-gray-900">{label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 leading-relaxed">{description}</div>
|
||||
</Col>
|
||||
<Col span={16} className="flex items-center">
|
||||
<div className="w-full">{isEditing ? editContent : viewContent}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
||||
const NotSet = () => <Text className="text-gray-400 italic">Not set</Text>;
|
||||
|
||||
const renderTags = (values: string[], displayFn?: (v: string) => string) => {
|
||||
if (!values || values.length === 0) return <NotSet />;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.map((v) => (
|
||||
<Tag key={v} color="blue">
|
||||
{displayFn ? displayFn(v) : v}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsValues {
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
tpm_limit: number | null;
|
||||
rpm_limit: number | null;
|
||||
models: string[];
|
||||
team_member_permissions: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: SettingsValues = {
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
models: [],
|
||||
team_member_permissions: [],
|
||||
};
|
||||
|
||||
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [editedValues, setEditedValues] = useState<any>({});
|
||||
const [editedValues, setEditedValues] = useState<SettingsValues>(DEFAULT_VALUES);
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const { Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
const [fetchError, setFetchError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTeamSSOSettings = async () => {
|
||||
const fetchSettings = async () => {
|
||||
if (!accessToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -32,39 +98,30 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
|
||||
|
||||
try {
|
||||
const data = await getDefaultTeamSettings(accessToken);
|
||||
setSettings(data);
|
||||
setEditedValues(data.values || {});
|
||||
|
||||
// Fetch available models
|
||||
if (accessToken) {
|
||||
try {
|
||||
const modelResponse = await modelAvailableCall(accessToken, userID, userRole);
|
||||
if (modelResponse && modelResponse.data) {
|
||||
const modelNames = modelResponse.data.map((model: { id: string }) => model.id);
|
||||
setAvailableModels(modelNames);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching available models:", error);
|
||||
}
|
||||
}
|
||||
const fetched = { ...DEFAULT_VALUES, ...(data.values || {}) };
|
||||
setValues(fetched);
|
||||
setEditedValues(fetched);
|
||||
} catch (error) {
|
||||
console.error("Error fetching team SSO settings:", error);
|
||||
setFetchError(true);
|
||||
NotificationsManager.fromBackend("Failed to fetch team settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTeamSSOSettings();
|
||||
fetchSettings();
|
||||
}, [accessToken]);
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
const handleSave = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updatedSettings = await updateDefaultTeamSettings(accessToken, editedValues);
|
||||
setSettings({ ...settings, values: updatedSettings.settings });
|
||||
const newValues = { ...DEFAULT_VALUES, ...(updatedSettings.settings || {}) };
|
||||
setValues(newValues);
|
||||
setEditedValues(newValues);
|
||||
setIsEditing(false);
|
||||
NotificationsManager.success("Default team settings updated successfully");
|
||||
} catch (error) {
|
||||
@@ -75,129 +132,13 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextInputChange = (key: string, value: any) => {
|
||||
setEditedValues((prev: Record<string, any>) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditedValues(values);
|
||||
};
|
||||
|
||||
const renderEditableField = (key: string, property: any, value: any) => {
|
||||
const type = property.type;
|
||||
|
||||
if (key === "budget_duration") {
|
||||
return (
|
||||
<BudgetDurationDropdown
|
||||
value={editedValues[key] || null}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
);
|
||||
} else if (type === "boolean") {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Switch checked={!!editedValues[key]} onChange={(checked) => handleTextInputChange(key, checked)} />
|
||||
</div>
|
||||
);
|
||||
} else if (type === "array" && property.items?.enum) {
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: "100%" }}
|
||||
value={editedValues[key] || []}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{property.items.enum.map((option: string) => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (key === "models") {
|
||||
return (
|
||||
<ModelSelect
|
||||
value={editedValues[key] || []}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
context="global"
|
||||
style={{ width: "100%" }}
|
||||
options={{
|
||||
includeSpecialOptions: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (type === "string" && property.enum) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={editedValues[key] || ""}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{property.enum.map((option: string) => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextInput
|
||||
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
|
||||
onChange={(e) => handleTextInputChange(key, e.target.value)}
|
||||
placeholder={property.description || ""}
|
||||
className="mt-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (key: string, value: any): JSX.Element => {
|
||||
if (value === null || value === undefined) return <span className="text-gray-400">Not set</span>;
|
||||
|
||||
if (key === "budget_duration") {
|
||||
return <span>{getBudgetDurationLabel(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return <span>{value ? "Enabled" : "Disabled"}</span>;
|
||||
}
|
||||
|
||||
if (key === "models" && Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-400">None</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{value.map((model, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-400">None</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{value.map((item, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{typeof item === "object" ? JSON.stringify(item) : String(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>;
|
||||
const update = <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => {
|
||||
setEditedValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -208,7 +149,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
if (fetchError) {
|
||||
return (
|
||||
<Card>
|
||||
<Text>No team settings available or you do not have permission to view them.</Text>
|
||||
@@ -216,70 +157,166 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken, userID,
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamically render settings based on the schema
|
||||
const renderSettings = () => {
|
||||
const { values, field_schema } = settings;
|
||||
|
||||
if (!field_schema || !field_schema.properties) {
|
||||
return <Text>No schema information available</Text>;
|
||||
}
|
||||
|
||||
return Object.entries(field_schema.properties).map(([key, property]: [string, any]) => {
|
||||
const value = values[key];
|
||||
const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
|
||||
return (
|
||||
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
|
||||
<Text className="font-medium text-lg">{displayName}</Text>
|
||||
<Paragraph className="text-sm text-gray-500 mt-1">
|
||||
{property.description || "No description available"}
|
||||
</Paragraph>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-2">{renderEditableField(key, property, value)}</div>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded">{renderValue(key, value)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title className="text-xl">Default Team Settings</Title>
|
||||
{!loading &&
|
||||
settings &&
|
||||
(isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setEditedValues(settings.values || {});
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<Card styles={{ body: { padding: 32 } }}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<Title level={3} className="m-0 text-gray-900">
|
||||
Default Team Settings
|
||||
</Title>
|
||||
<Text className="text-gray-500 mt-1 block">
|
||||
These settings will be applied by default when creating new teams.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveSettings} loading={saving}>
|
||||
<Button type="primary" onClick={handleSave} loading={saving} icon={<SaveOutlined />}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
|
||||
))}
|
||||
<Button onClick={() => setIsEditing(true)} icon={<EditOutlined />}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text>These settings will be applied by default when creating new teams.</Text>
|
||||
<div className="mt-8">
|
||||
{/* Budget & Rate Limits */}
|
||||
<div className="mb-8">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Budget & Rate Limits</div>
|
||||
<div className="border-t border-gray-100">
|
||||
<SettingRow
|
||||
label="Max Budget"
|
||||
description="Maximum budget (in USD) for new automatically created teams."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.max_budget != null ? <Text>${Number(values.max_budget).toLocaleString()}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
style={{ maxWidth: 320 }}
|
||||
value={editedValues.max_budget}
|
||||
onChange={(v) => update("max_budget", v)}
|
||||
placeholder="Not set"
|
||||
prefix="$"
|
||||
min={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{settings?.field_schema?.description && (
|
||||
<Paragraph className="mb-4 mt-2">{settings.field_schema.description}</Paragraph>
|
||||
)}
|
||||
<Divider />
|
||||
<SettingRow
|
||||
label="Budget Duration"
|
||||
description="How frequently the team's budget resets."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.budget_duration ? <Text>{getBudgetDurationLabel(values.budget_duration)}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<BudgetDurationDropdown
|
||||
value={editedValues.budget_duration || null}
|
||||
onChange={(v) => update("budget_duration", v)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">{renderSettings()}</div>
|
||||
<SettingRow
|
||||
label="TPM Limit"
|
||||
description="Maximum tokens per minute allowed across all models."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.tpm_limit != null ? <Text>{values.tpm_limit.toLocaleString()}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
style={{ maxWidth: 320 }}
|
||||
value={editedValues.tpm_limit}
|
||||
onChange={(v) => update("tpm_limit", v)}
|
||||
placeholder="Not set"
|
||||
min={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="RPM Limit"
|
||||
description="Maximum requests per minute allowed across all models."
|
||||
isEditing={isEditing}
|
||||
viewContent={
|
||||
values.rpm_limit != null ? <Text>{values.rpm_limit.toLocaleString()}</Text> : <NotSet />
|
||||
}
|
||||
editContent={
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
style={{ maxWidth: 320 }}
|
||||
value={editedValues.rpm_limit}
|
||||
onChange={(v) => update("rpm_limit", v)}
|
||||
placeholder="Not set"
|
||||
min={0}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access & Permissions */}
|
||||
<div className="mb-8">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Access & Permissions</div>
|
||||
<div className="border-t border-gray-100">
|
||||
<SettingRow
|
||||
label="Models"
|
||||
description="Default list of models that new teams can access."
|
||||
isEditing={isEditing}
|
||||
viewContent={renderTags(values.models, getModelDisplayName)}
|
||||
editContent={
|
||||
<ModelSelect
|
||||
value={editedValues.models || []}
|
||||
onChange={(v) => update("models", v)}
|
||||
context="global"
|
||||
style={{ width: "100%" }}
|
||||
options={{ includeSpecialOptions: true }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="Team Member Permissions"
|
||||
description="Default permissions granted to members of newly created teams. /key/info and /key/health are always included."
|
||||
isEditing={isEditing}
|
||||
viewContent={renderTags(values.team_member_permissions)}
|
||||
editContent={
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: "100%" }}
|
||||
value={editedValues.team_member_permissions || []}
|
||||
onChange={(v) => update("team_member_permissions", v)}
|
||||
placeholder="Select permissions"
|
||||
tagRender={({ label, closable, onClose }) => (
|
||||
<Tag color="blue" closable={closable} onClose={onClose} className="mr-1 mt-1 mb-1">
|
||||
{label}
|
||||
</Tag>
|
||||
)}
|
||||
>
|
||||
{PERMISSION_OPTIONS.map((option) => (
|
||||
<Select.Option key={option} value={option}>
|
||||
{option}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7206,7 +7206,6 @@ export const updateDefaultTeamSettings = async (accessToken: string, settings: R
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Updated default team settings:", data);
|
||||
NotificationsManager.success("Default team settings updated successfully");
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to update default team settings:", error);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user