feat(team): opt-in self-service team creation via general_settings.allow_user_team_creation

Add an opt-in `allow_user_team_creation` flag (default False) that lets internal
users create their own standalone teams instead of asking a proxy admin. When
enabled, an INTERNAL_USER may call POST /team/new for a team with no
organization_id; the existing handler already makes the creator the team admin.

The route gate is scoped tightly: only INTERNAL_USER, only /team/new, only when
the request carries no organization_id, and only when the flag is on. Org-scoped
requests still fall through to the existing org-admin path so a user cannot mint
a team inside an organization they do not belong to.

A self-served team is clamped to the creating user's effective limits so it can
never be wider than its creator. The clamp uses the tightest of the calling
key's and the underlying user's tpm/rpm caps, and the user's own max_budget; the
per-session UI key budget is deliberately excluded. Values seeded by
default_team_params that exceed the caller's ceiling are clamped down rather than
rejected, so the feature stays usable under an admin default that is higher than
a given user's cap.

The flag is surfaced in the Admin UI Settings so an admin can toggle it, and the
Teams page shows Create Team to internal users when it is on. The create form no
longer forces a non-admin into a required-but-empty organization dropdown.
This commit is contained in:
ryan-crabbe-berri 2026-07-09 23:23:34 -07:00
parent 3d5d5e1295
commit 942a14d26a
12 changed files with 762 additions and 42 deletions

View file

@ -2251,6 +2251,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
description="sends alerts if requests hang for 5min+",
)
ui_access_mode: Optional[Literal["admin_only", "all"]] = Field("all", description="Control access to the Proxy UI")
allow_user_team_creation: bool = Field(
default=False,
description="When True, internal users can call POST /team/new to create standalone teams (no organization_id). The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.",
)
allowed_routes: Optional[List] = Field(None, description="Proxy API Endpoints you want users to be able to access")
reject_clientside_metadata_tags: Optional[bool] = Field(
None,

View file

@ -288,6 +288,13 @@ class RouteChecks:
request_data=request_data,
request=request,
)
elif (
_user_role == LitellmUserRoles.INTERNAL_USER.value
and route == "/team/new"
and request_data.get("organization_id") is None
and RouteChecks._user_team_creation_enabled()
):
pass
elif _user_role == LitellmUserRoles.INTERNAL_USER.value and RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value
):
@ -326,6 +333,12 @@ class RouteChecks:
else:
RouteChecks._raise_admin_only_route_exception(user_obj=user_obj, route=route)
@staticmethod
def _user_team_creation_enabled() -> bool:
from litellm.proxy.proxy_server import general_settings
return general_settings.get("allow_user_team_creation", False) is True
@staticmethod
def custom_admin_only_route_check(route: str):
from litellm.proxy.proxy_server import general_settings, premium_user

View file

@ -74,6 +74,7 @@ from litellm.proxy.auth.auth_checks import (
get_team_object,
get_user_object,
)
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.management_endpoints.common_utils import (
@ -784,6 +785,42 @@ async def _check_org_team_limits(
)
def _tightest_cap(*caps: Optional[float]) -> Optional[float]:
set_caps = tuple(cap for cap in caps if cap is not None)
return min(set_caps) if set_caps else None
def _inherit_caller_limits_for_self_served_team(
data: NewTeamRequest,
user_api_key_dict: UserAPIKeyAuth,
) -> NewTeamRequest:
"""Clamp a self-served team to the creating user's effective limits.
A self-served team must never be wider than its creator. The creator's
authority spans two layers: the calling key (`tpm_limit`/`rpm_limit`/
`models`) and the underlying user (`user_tpm_limit`/`user_rpm_limit`/
`user_max_budget`). The primary caller is a UI SSO session whose key
carries no tpm/rpm and only a tiny per-session `max_budget`
(`max_ui_session_budget`), so reading key limits alone would leave the team
uncapped. We take the tightest of both layers per dimension and clamp the
team down to it, filling unset fields and shrinking any value (including one
seeded by `default_team_params`) that exceeds the creator's ceiling. The
per-session key budget is deliberately excluded from the budget cap; only
the user's own `max_budget` bounds the team.
"""
effective_tpm = _tightest_cap(user_api_key_dict.tpm_limit, user_api_key_dict.user_tpm_limit)
effective_rpm = _tightest_cap(user_api_key_dict.rpm_limit, user_api_key_dict.user_rpm_limit)
effective_budget = user_api_key_dict.user_max_budget
return data.model_copy(
update={
"models": data.models if data.models else list(user_api_key_dict.models),
"tpm_limit": _tightest_cap(data.tpm_limit, effective_tpm),
"rpm_limit": _tightest_cap(data.rpm_limit, effective_rpm),
"max_budget": _tightest_cap(data.max_budget, effective_budget),
}
)
async def _check_user_team_limits(
data: Union[NewTeamRequest, UpdateTeamRequest],
user_api_key_dict: UserAPIKeyAuth,
@ -1115,6 +1152,14 @@ async def new_team(
# Only validate user budget/models/tpm/rpm for standalone teams (not org-scoped)
# For org-scoped teams, validation is done by _check_org_team_limits()
if data.organization_id is None:
if (
user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER
and RouteChecks._user_team_creation_enabled()
):
data = _inherit_caller_limits_for_self_served_team(
data=data,
user_api_key_dict=user_api_key_dict,
)
await _check_user_team_limits(
data=data,
user_api_key_dict=user_api_key_dict,

View file

@ -182,6 +182,11 @@ class UISettings(BaseModel):
description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
)
allow_user_team_creation: bool = Field(
default=False,
description="If true, internal users can create their own standalone teams via POST /team/new and the Teams page. The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.",
)
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@ -206,6 +211,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
"allow_user_team_creation",
}
# Flags that must be synced from the persisted UISettings into
@ -219,6 +225,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS = [
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"disable_key_generate_for_org_admin",
"allow_user_team_creation",
]
# Extension point: packages outside OSS (e.g. litellm_enterprise) can

View file

@ -2926,3 +2926,109 @@ def test_internal_user_blocked_from_search_tool_writes(route):
assert "Only proxy admin" in str(exc_info.value)
assert f"Route={route}" in str(exc_info.value)
assert "Your role=internal_user" in str(exc_info.value)
def _self_serve_team_new_check(
route: str,
general_settings: dict,
role: str = LitellmUserRoles.INTERNAL_USER.value,
request_data: dict | None = None,
):
"""Run non_proxy_admin_allowed_routes_check for the LIT-3254 self-serve
team creation gate with the given general_settings patched in."""
user_obj = LiteLLM_UserTable(
user_id="self-serve-user",
user_email="user@example.com",
user_role=role,
)
valid_token = UserAPIKeyAuth(
user_id="self-serve-user",
user_role=role,
)
request = MagicMock(spec=Request)
request.query_params = {}
request.method = "POST"
with (
patch("litellm.proxy.proxy_server.general_settings", general_settings),
patch("litellm.proxy.proxy_server.premium_user", True),
):
return RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=role,
route=route,
request=request,
valid_token=valid_token,
request_data=request_data if request_data is not None else {},
)
@pytest.mark.parametrize(
"general_settings",
[{}, {"allow_user_team_creation": False}, {"allow_user_team_creation": "yes"}],
)
def test_internal_user_team_new_blocked_unless_flag_enabled(general_settings):
"""LIT-3254: /team/new stays admin-only when allow_user_team_creation is
absent, False, or any non-boolean truthy value."""
with pytest.raises(Exception) as exc_info:
_self_serve_team_new_check("/team/new", general_settings)
assert "Only proxy admin" in str(exc_info.value)
@pytest.mark.parametrize(
"request_data",
[{}, {"organization_id": None}, {"team_alias": "my-team"}],
)
def test_internal_user_team_new_allowed_when_flag_enabled(request_data):
"""LIT-3254: with the flag on, an internal user may create a standalone
team (no organization_id in the request body)."""
result = _self_serve_team_new_check(
"/team/new",
{"allow_user_team_creation": True},
request_data=request_data,
)
assert result is None
def test_internal_user_team_new_with_organization_id_blocked_despite_flag():
"""LIT-3254 cross-org bypass guard: the self-serve gate must not admit
org-scoped requests. new_team only checks that the organization row
exists, not that the caller belongs to it, so an org-scoped request from
a non-org-admin must keep failing at the route layer."""
with pytest.raises(Exception) as exc_info:
_self_serve_team_new_check(
"/team/new",
{"allow_user_team_creation": True},
request_data={"organization_id": "org-i-dont-belong-to"},
)
assert "Only proxy admin" in str(exc_info.value)
@pytest.mark.parametrize(
"route",
["/team/delete", "/team/update", "/team/block", "/user/new", "/organization/new"],
)
def test_flag_scoped_to_team_new_only(route):
"""LIT-3254: the flag opens exactly one route. Every other management
route must keep raising for an internal user even with the flag on."""
with pytest.raises(Exception) as exc_info:
_self_serve_team_new_check(route, {"allow_user_team_creation": True})
assert "Only proxy admin" in str(exc_info.value)
@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
LitellmUserRoles.TEAM.value,
],
)
def test_flag_scoped_to_internal_user_role_only(role):
"""LIT-3254: view-only and team-scoped roles must stay blocked from
/team/new regardless of allow_user_team_creation."""
with pytest.raises(Exception):
_self_serve_team_new_check(
"/team/new",
{"allow_user_team_creation": True},
role=role,
)

View file

@ -9535,3 +9535,306 @@ async def test_new_team_rejects_reserved_ui_session_team_id():
assert exc_info.value.code == "400"
assert "reserved" in str(exc_info.value.message)
mock_prisma.get_data.assert_not_called()
class TestSelfServeTeamLimitInheritance:
"""LIT-3254: _inherit_caller_limits_for_self_served_team fills unset team
fields from the caller so a self-served team can never be wider than its
creator."""
def _caller(self, **kwargs):
from litellm.proxy._types import UserAPIKeyAuth
return UserAPIKeyAuth(api_key="sk-test", user_id="u1", **kwargs)
def test_inherits_models_when_unset_or_empty(self):
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(models=["gpt-5", "gpt-5-mini"])
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t", models=[]),
user_api_key_dict=caller,
)
assert result.models == ["gpt-5", "gpt-5-mini"]
def test_inherits_key_tpm_and_rpm_when_unset(self):
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(tpm_limit=1000, rpm_limit=10)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t"),
user_api_key_dict=caller,
)
assert result.tpm_limit == 1000
assert result.rpm_limit == 10
def test_inherits_user_level_limits_when_key_has_none(self):
"""Finding 7: a UI SSO session key carries no tpm/rpm and only a tiny
per-session budget, so the team must inherit the USER-level caps or it
is created uncapped despite a rate/budget-limited creator."""
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(
tpm_limit=None,
rpm_limit=None,
max_budget=0.25, # ephemeral UI-session budget, must NOT propagate
user_tpm_limit=100,
user_rpm_limit=5,
user_max_budget=10.0,
)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t"),
user_api_key_dict=caller,
)
assert result.tpm_limit == 100
assert result.rpm_limit == 5
assert result.max_budget == 10.0
def test_uses_tightest_of_key_and_user_limits(self):
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(tpm_limit=100, user_tpm_limit=1000, rpm_limit=50, user_rpm_limit=5)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t"),
user_api_key_dict=caller,
)
assert result.tpm_limit == 100
assert result.rpm_limit == 5
def test_clamps_over_cap_values_down_to_caller(self):
"""A value seeded by default_team_params (or an explicit over-request)
that exceeds the creator's ceiling is clamped, not rejected — so the
feature stays usable when an admin default is higher than a user cap."""
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(user_tpm_limit=100, user_rpm_limit=5, user_max_budget=10.0)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t", tpm_limit=100000, rpm_limit=9999, max_budget=1000.0),
user_api_key_dict=caller,
)
assert result.tpm_limit == 100
assert result.rpm_limit == 5
assert result.max_budget == 10.0
def test_under_cap_values_preserved(self):
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(models=["a", "b"], user_tpm_limit=5000, user_rpm_limit=100, user_max_budget=100.0)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t", models=["a"], tpm_limit=1000, rpm_limit=50, max_budget=25.0),
user_api_key_dict=caller,
)
assert result.models == ["a"]
assert result.tpm_limit == 1000
assert result.rpm_limit == 50
assert result.max_budget == 25.0
def test_unrestricted_caller_leaves_team_unrestricted(self):
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(
models=[],
tpm_limit=None,
rpm_limit=None,
user_tpm_limit=None,
user_rpm_limit=None,
user_max_budget=None,
)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t"),
user_api_key_dict=caller,
)
assert result.models == []
assert result.tpm_limit is None
assert result.rpm_limit is None
assert result.max_budget is None
def test_session_key_budget_not_propagated(self):
"""The tiny per-session key budget must never become the team budget;
only the user's own max_budget bounds the team."""
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import (
_inherit_caller_limits_for_self_served_team,
)
caller = self._caller(max_budget=0.25, user_max_budget=None)
result = _inherit_caller_limits_for_self_served_team(
data=NewTeamRequest(team_alias="t"),
user_api_key_dict=caller,
)
assert result.max_budget is None
@pytest.mark.parametrize(
"flag_enabled,caller_role,expect_inherited",
[
(True, LitellmUserRoles.INTERNAL_USER, True),
(False, LitellmUserRoles.INTERNAL_USER, False),
(True, LitellmUserRoles.ORG_ADMIN, False),
],
)
@pytest.mark.asyncio
async def test_new_team_self_serve_inheritance_call_site(flag_enabled, caller_role, expect_inherited):
"""LIT-3254: new_team applies caller-limit inheritance exactly when
allow_user_team_creation is on AND the caller is an INTERNAL_USER.
Org admins and flag-off callers keep today's behavior (an omitted models
list stays empty)."""
from fastapi import Request
from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import new_team
caller = UserAPIKeyAuth(
user_role=caller_role,
user_id="self-serve-user-1",
models=["gpt-5"],
tpm_limit=1000,
rpm_limit=10,
)
team_request = NewTeamRequest(team_alias="self-serve-team")
dummy_request = MagicMock(spec=Request)
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server._license_check") as mock_license,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch("litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()),
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_user_team_creation": flag_enabled},
),
):
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
mock_license.is_team_count_over_limit.return_value = False
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_prisma.get_data = AsyncMock(return_value=None)
mock_prisma.update_data = AsyncMock()
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_created_team = MagicMock()
mock_created_team.team_id = "team-self-serve-1"
mock_created_team.members_with_roles = []
mock_created_team.metadata = None
mock_created_team.default_team_member_models = None
mock_created_team.model_dump.return_value = {"team_id": "team-self-serve-1"}
mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team)
mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team)
mock_prisma.db.litellm_modeltable = MagicMock()
mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123"))
mock_user = MagicMock()
mock_user.user_id = "self-serve-user-1"
mock_user.model_dump.return_value = {
"user_id": "self-serve-user-1",
"teams": ["team-self-serve-1"],
}
mock_prisma.db.litellm_usertable = MagicMock()
mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user)
mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
mock_membership = MagicMock()
mock_membership.model_dump.return_value = {
"team_id": "team-self-serve-1",
"user_id": "self-serve-user-1",
"budget_id": None,
}
mock_prisma.db.litellm_teammembership = MagicMock()
mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership)
await new_team(
data=team_request,
http_request=dummy_request,
user_api_key_dict=caller,
)
created_row = mock_prisma.db.litellm_teamtable.create.call_args.kwargs["data"]
if expect_inherited:
assert created_row["models"] == ["gpt-5"]
assert created_row["tpm_limit"] == 1000
assert created_row["rpm_limit"] == 10
else:
assert created_row["models"] == []
assert created_row.get("tpm_limit") is None
assert created_row.get("rpm_limit") is None
@pytest.mark.asyncio
async def test_new_team_self_serve_creator_becomes_team_admin():
"""LIT-3254: a self-served team must include its creator as a team admin
in members_with_roles."""
from fastapi import Request
from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import new_team
caller = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="self-serve-user-2",
models=[],
)
team_request = NewTeamRequest(team_alias="self-serve-admin-team")
dummy_request = MagicMock(spec=Request)
with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache,
patch("litellm.proxy.proxy_server._license_check") as mock_license,
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
patch("litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()),
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_user_team_creation": True},
),
patch(
"litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team",
new=AsyncMock(),
) as mock_add_members,
):
mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0)
mock_license.is_team_count_over_limit.return_value = False
mock_prisma.jsonify_team_object = lambda db_data: db_data
mock_prisma.get_data = AsyncMock(return_value=None)
mock_prisma.update_data = AsyncMock()
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_created_team = MagicMock()
mock_created_team.team_id = "team-self-serve-2"
mock_created_team.members_with_roles = []
mock_created_team.metadata = None
mock_created_team.default_team_member_models = None
mock_created_team.model_dump.return_value = {"team_id": "team-self-serve-2"}
mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team)
mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team)
mock_prisma.db.litellm_modeltable = MagicMock()
mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123"))
await new_team(
data=team_request,
http_request=dummy_request,
user_api_key_dict=caller,
)
add_request = mock_add_members.call_args.kwargs["data"]
assert [(m.user_id, m.role) for m in add_request.member] == [("self-serve-user-2", "admin")]

View file

@ -2393,3 +2393,19 @@ def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch):
assert "proxy admin" in resp.json()["detail"].lower()
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
def test_allow_user_team_creation_is_wired_into_ui_settings_sync():
"""LIT-3254: the allow_user_team_creation UI toggle only reaches the
/team/new route gate through the general_settings runtime sync. If the
flag drops out of either list, the DB-persisted toggle silently stops
working while the UI still shows it as enabled."""
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
ALLOWED_UI_SETTINGS_FIELDS,
_RUNTIME_GENERAL_SETTINGS_FLAGS,
UISettings,
)
assert "allow_user_team_creation" in ALLOWED_UI_SETTINGS_FIELDS
assert "allow_user_team_creation" in _RUNTIME_GENERAL_SETTINGS_FLAGS
assert UISettings.model_fields["allow_user_team_creation"].default is False

View file

@ -3,8 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
import OldTeams from "./OldTeams";
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall, type Organization } from "./networking";
import OldTeams, { canCreateOrManageTeams } from "./OldTeams";
import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
const mockTeamInfoView = vi.fn();
@ -95,6 +95,12 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: () => mockUseOrganizations(),
}));
const mockUseUISettings = vi.fn();
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => mockUseUISettings() ?? { data: undefined },
}));
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: vi.fn().mockReturnValue({
data: [
@ -131,6 +137,17 @@ const renderWithQueryClient = (component: React.ReactElement) => {
return render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
};
const emptyTeamListResult = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 };
const selfServeCreatedTeam = {
team_id: "new-team-1",
team_alias: "My Self Serve Team",
models: ["gpt-4"],
organization_id: null,
keys: [],
members_with_roles: [],
spend: 0,
};
describe("OldTeams - handleCreate organization handling", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -529,48 +546,39 @@ describe("OldTeams - helper functions", () => {
});
describe("canCreateOrManageTeams", () => {
it("should return true for Admin role", () => {
const userRole = "Admin";
const result = userRole === "Admin";
expect(result).toBe(true);
const orgWithOrgAdmin = {
organization_id: "org-1",
organization_alias: "Org 1",
models: [],
members: [{ user_id: "user-123", user_role: "org_admin" }],
} as unknown as Organization;
const orgWithPlainMember = {
organization_id: "org-1",
organization_alias: "Org 1",
models: [],
members: [{ user_id: "user-123", user_role: "member" }],
} as unknown as Organization;
it("returns true for Admin role regardless of the self-serve flag", () => {
expect(canCreateOrManageTeams("Admin", "user-123", null, false)).toBe(true);
});
it("should return true for org_admin in any organization", () => {
const userID = "user-123";
const organizations = [
{
organization_id: "org-1",
organization_alias: "Org 1",
models: [],
members: [{ user_id: "user-123", user_role: "org_admin" }],
},
];
const result = organizations.some((org) =>
org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
);
expect(result).toBe(true);
it("returns true for org_admin in any organization regardless of the self-serve flag", () => {
expect(canCreateOrManageTeams("Internal User", "user-123", [orgWithOrgAdmin], false)).toBe(true);
});
it("should return false when user has no admin permissions", () => {
const userID = "user-123";
const userRole: string = "User";
const organizations = [
{
organization_id: "org-1",
organization_alias: "Org 1",
models: [],
members: [{ user_id: "user-123", user_role: "member" }],
},
];
it("returns true for Internal User when allow_user_team_creation is on", () => {
expect(canCreateOrManageTeams("Internal User", "user-123", null, true)).toBe(true);
});
const isAdmin = userRole === "Admin";
const isOrgAdmin = organizations.some((org) =>
org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"),
);
it("returns false for Internal User when allow_user_team_creation is off", () => {
expect(canCreateOrManageTeams("Internal User", "user-123", [orgWithPlainMember], false)).toBe(false);
});
expect(isAdmin || isOrgAdmin).toBe(false);
it("does not extend the self-serve flag to viewer roles", () => {
expect(canCreateOrManageTeams("Internal Viewer", "user-123", null, true)).toBe(false);
expect(canCreateOrManageTeams("Admin Viewer", "user-123", null, true)).toBe(false);
});
});
});
@ -1146,3 +1154,97 @@ describe("OldTeams - LIT-2530 organization stays optional for proxy admin with a
});
});
});
describe("OldTeams - self-serve team creation (LIT-3254)", () => {
beforeEach(() => {
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(teamListCall).mockResolvedValue(emptyTeamListResult);
mockUseOrganizations.mockReturnValue({ data: [] });
});
it("shows the Create Team button to an Internal User when allow_user_team_creation is on", async () => {
mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
renderWithQueryClient(<OldTeams accessToken="test-token" userID="user-123" userRole="Internal User" />);
const buttons = await screen.findAllByTestId("create-team-button");
expect(buttons.length).toBeGreaterThan(0);
});
it("hides the Create Team button from an Internal User when allow_user_team_creation is off", async () => {
mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: false } } });
renderWithQueryClient(<OldTeams accessToken="test-token" userID="user-123" userRole="Internal User" />);
await waitFor(() => expect(screen.getAllByText("Teams").length).toBeGreaterThan(0));
expect(screen.queryByTestId("create-team-button")).not.toBeInTheDocument();
});
it("omits the Organization selector for a self-serve Internal User with no admin orgs", async () => {
mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
renderWithQueryClient(<OldTeams accessToken="test-token" userID="user-123" userRole="Internal User" />);
const buttons = await screen.findAllByTestId("create-team-button");
fireEvent.click(buttons[0]);
await waitFor(() => expect(screen.getByTestId("team-name-input")).toBeInTheDocument());
expect(document.getElementById("organization_id")).toBeNull();
});
it("submits a standalone team creation for a self-serve Internal User", async () => {
mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(teamCreateCall).mockResolvedValue(selfServeCreatedTeam);
renderWithQueryClient(<OldTeams accessToken="test-token" userID="user-123" userRole="Internal User" />);
const buttons = await screen.findAllByTestId("create-team-button");
act(() => {
fireEvent.click(buttons[0]);
});
await waitFor(() => expect(screen.getByLabelText(/team name/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "My Self Serve Team" } });
fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } });
const submitButtons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(submitButtons[submitButtons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalledWith(
"test-token",
expect.objectContaining({
team_alias: "My Self Serve Team",
models: ["gpt-4"],
organization_id: null,
}),
);
});
});
it("keeps the required Organization selector for an org admin when the flag is on", async () => {
mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } });
mockUseOrganizations.mockReturnValue({
data: [
{
organization_id: "org-1",
organization_alias: "Org 1",
models: [],
members: [{ user_id: "user-123", user_role: "org_admin" }],
},
],
});
renderWithQueryClient(<OldTeams accessToken="test-token" userID="user-123" userRole="Internal User" />);
const buttons = await screen.findAllByTestId("create-team-button");
fireEvent.click(buttons[0]);
await waitFor(() => expect(screen.getByTestId("team-name-input")).toBeInTheDocument());
expect(document.getElementById("organization_id")).not.toBeNull();
});
});

View file

@ -1,4 +1,5 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import AvailableTeamsPanel from "@/components/team/available_teams";
import TeamInfoView from "@/components/team/TeamInfo";
import TeamSSOSettings from "@/components/TeamSSOSettings";
@ -108,16 +109,21 @@ const getOrganizationModels = (organization: Organization | null, userModels: st
return unfurlWildcardModelsInList(tempModelsToPick, userModels);
};
const canCreateOrManageTeams = (
export const canCreateOrManageTeams = (
userRole: string | null,
userID: string | null,
organizations: Organization[] | null,
allowUserTeamCreation: boolean,
): boolean => {
// Admin role always has permission
if (userRole === "Admin") {
return true;
}
if (allowUserTeamCreation && userRole === "Internal User") {
return true;
}
// Check if user is an org_admin in any organization
if (organizations && userID) {
return organizations.some((org) =>
@ -164,6 +170,8 @@ const getOrganizationAlias = (
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
const { data: organizationsData } = useOrganizations();
const organizations = organizationsData ?? null;
const { data: uiSettings } = useUISettings();
const allowUserTeamCreation = Boolean(uiSettings?.values?.allow_user_team_creation);
const [teams, setTeams] = useState<Team[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
@ -879,7 +887,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
Create your first team to organize members and manage access to models.
</Text>
</div>
{canCreateOrManageTeams(userRole, userID, organizations) && (
{canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (
<Button
type="primary"
icon={<PlusOutlined />}
@ -1026,7 +1034,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
</Title>
<Text type="secondary">Manage teams, members, and their access to models and budgets</Text>
</Space>
{canCreateOrManageTeams(userRole, userID, organizations) && (
{canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (
<Button
type="primary"
icon={<PlusOutlined />}
@ -1042,7 +1050,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
</>
)}
{canCreateOrManageTeams(userRole, userID, organizations) && (
{canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (
<Modal
title="Create Team"
open={isTeamModalVisible}
@ -1071,6 +1079,10 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
const isSingleOrg = adminOrgs.length === 1;
const hasNoOrgs = adminOrgs.length === 0;
if (userRole !== "Admin" && hasNoOrgs) {
return null;
}
return (
<>
<Form.Item

View file

@ -163,3 +163,75 @@ describe("UISettings", () => {
expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully");
});
});
describe("UISettings - allow_user_team_creation (LIT-3254)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseUISettings.mockReturnValue(buildSettingsResponse());
mockUseUpdateUISettings.mockReturnValue({
mutate: vi.fn(),
isPending: false,
error: null,
});
});
it("toggles self-serve team creation on and calls update with the right key", () => {
const mutateMock = vi.fn((_settings, options) => {
options?.onSuccess?.();
});
mockUseUpdateUISettings.mockReturnValue({
mutate: mutateMock,
isPending: false,
error: null,
});
render(<UISettings />);
const toggle = screen.getByRole("switch", { name: "Allow internal users to create teams" });
expect(toggle).not.toBeChecked();
act(() => {
fireEvent.click(toggle);
});
expect(mutateMock).toHaveBeenCalledWith(
{ allow_user_team_creation: true },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
expect(NotificationManager.success).toHaveBeenCalledWith("UI settings updated successfully");
});
it("toggles self-serve team creation off when currently enabled", () => {
const mutateMock = vi.fn();
mockUseUISettings.mockReturnValue(
buildSettingsResponse({
data: {
field_schema: { description: "UI settings description", properties: {} },
values: { allow_user_team_creation: true },
},
}),
);
mockUseUpdateUISettings.mockReturnValue({
mutate: mutateMock,
isPending: false,
error: null,
});
render(<UISettings />);
const toggle = screen.getByRole("switch", { name: "Allow internal users to create teams" });
expect(toggle).toBeChecked();
act(() => {
fireEvent.click(toggle);
});
expect(mutateMock).toHaveBeenCalledWith({ allow_user_team_creation: false }, expect.anything());
});
});

View file

@ -27,6 +27,7 @@ export default function UISettings() {
const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins;
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const allowUserTeamCreationProperty = schema?.properties?.allow_user_team_creation;
const values = data?.values ?? {};
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@ -228,6 +229,20 @@ export default function UISettings() {
);
};
const handleToggleAllowUserTeamCreation = (checked: boolean) => {
updateSettings(
{ allow_user_team_creation: checked },
{
onSuccess: () => {
NotificationManager.success("UI settings updated successfully");
},
onError: (error) => {
NotificationManager.fromBackend(error);
},
},
);
};
return (
<Card title="UI Settings">
{isLoading ? (
@ -484,6 +499,25 @@ export default function UISettings() {
<Divider />
<Space align="start" size="middle">
<Switch
checked={Boolean(values.allow_user_team_creation)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleAllowUserTeamCreation}
aria-label={allowUserTeamCreationProperty?.description ?? "Allow internal users to create teams"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Allow internal users to create teams</Typography.Text>
<Typography.Text type="secondary">
{allowUserTeamCreationProperty?.description ??
"If true, internal users can create their own standalone teams. The creating user is automatically added as the team's admin."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}

View file

@ -22333,6 +22333,12 @@ export interface components {
* @description opt-in to RFC 8628 verification_uri_complete for the CLI SSO device flow, pre-filling the user_code in the browser. Off by default; intended for same-host clients where the device that starts the flow and the browser run on the same machine
*/
allow_cli_sso_verification_uri_complete?: boolean | null;
/**
* Allow User Team Creation
* @description When True, internal users can call POST /team/new to create standalone teams (no organization_id). The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.
* @default false
*/
allow_user_team_creation: boolean;
/**
* Allowed Routes
* @description Proxy API Endpoints you want users to be able to access