mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
Easier user onboarding via SSO (#8187)
* fix(ui_sso.py): use common `get_user_object` logic across jwt + ui sso auth Allows finding users by their email, and attaching the sso user id to the user if found * Improve Team Management flow on UI (#8204) * build(teams.tsx): refactor teams page to make it easier to add members to a team make a row in table clickable -> allows user to add users to team they intended * build(teams.tsx): make it clear user should click on team id to view team details simplifies team management by putting team details on separate page * build(team_info.tsx): separately show user id and user email make it easy for user to understand the information they're seeing * build(team_info.tsx): add back in 'add member' button * build(team_info.tsx): working team member update on team_info.tsx * build(team_info.tsx): enable team member delete on ui allow user to delete accidental adds * build(internal_user_endpoints.py): expose new endpoint for ui to allow filtering on user table allows proxy admin to quickly find user they're looking for * feat(team_endpoints.py): expose new team filter endpoint for ui allows proxy admin to easily find team they're looking for * feat(user_search_modal.tsx): allow admin to filter on users when adding new user to teams * test: mark flaky test * test: mark flaky test * fix(exception_mapping_utils.py): fix anthropic text route error * fix(ui_sso.py): handle situation when user not in db
This commit is contained in:
parent
8900b18504
commit
65d3f85a69
14 changed files with 862 additions and 111 deletions
|
@ -14,6 +14,7 @@ from ..exceptions import (
|
|||
BadRequestError,
|
||||
ContentPolicyViolationError,
|
||||
ContextWindowExceededError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
|
@ -467,7 +468,10 @@ def exception_type( # type: ignore # noqa: PLR0915
|
|||
method="POST", url="https://api.openai.com/v1/"
|
||||
),
|
||||
)
|
||||
elif custom_llm_provider == "anthropic": # one of the anthropics
|
||||
elif (
|
||||
custom_llm_provider == "anthropic"
|
||||
or custom_llm_provider == "anthropic_text"
|
||||
): # one of the anthropics
|
||||
if "prompt is too long" in error_str or "prompt: length" in error_str:
|
||||
exception_mapping_worked = True
|
||||
raise ContextWindowExceededError(
|
||||
|
@ -475,6 +479,13 @@ def exception_type( # type: ignore # noqa: PLR0915
|
|||
model=model,
|
||||
llm_provider="anthropic",
|
||||
)
|
||||
elif "overloaded_error" in error_str:
|
||||
exception_mapping_worked = True
|
||||
raise InternalServerError(
|
||||
message="AnthropicError - {}".format(error_str),
|
||||
model=model,
|
||||
llm_provider="anthropic",
|
||||
)
|
||||
if "Invalid API Key" in error_str:
|
||||
exception_mapping_worked = True
|
||||
raise AuthenticationError(
|
||||
|
|
|
@ -926,3 +926,81 @@ async def add_internal_user_to_organization(
|
|||
return new_membership
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to add user to organization: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user/filter/ui",
|
||||
tags=["Internal User management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
include_in_schema=False,
|
||||
responses={
|
||||
200: {"model": List[LiteLLM_UserTable]},
|
||||
},
|
||||
)
|
||||
async def ui_view_users(
|
||||
user_id: Optional[str] = fastapi.Query(
|
||||
default=None, description="User ID in the request parameters"
|
||||
),
|
||||
user_email: Optional[str] = fastapi.Query(
|
||||
default=None, description="User email in the request parameters"
|
||||
),
|
||||
page: int = fastapi.Query(
|
||||
default=1, description="Page number for pagination", ge=1
|
||||
),
|
||||
page_size: int = fastapi.Query(
|
||||
default=50, description="Number of items per page", ge=1, le=100
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
[PROXY-ADMIN ONLY]Filter users based on partial match of user_id or email with pagination.
|
||||
|
||||
Args:
|
||||
user_id (Optional[str]): Partial user ID to search for
|
||||
user_email (Optional[str]): Partial email to search for
|
||||
page (int): Page number for pagination (starts at 1)
|
||||
page_size (int): Number of items per page (max 100)
|
||||
user_api_key_dict (UserAPIKeyAuth): User authentication information
|
||||
|
||||
Returns:
|
||||
List[LiteLLM_SpendLogs]: Paginated list of matching user records
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
|
||||
try:
|
||||
# Calculate offset for pagination
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Build where conditions based on provided parameters
|
||||
where_conditions = {}
|
||||
|
||||
if user_id:
|
||||
where_conditions["user_id"] = {
|
||||
"contains": user_id,
|
||||
"mode": "insensitive", # Case-insensitive search
|
||||
}
|
||||
|
||||
if user_email:
|
||||
where_conditions["user_email"] = {
|
||||
"contains": user_email,
|
||||
"mode": "insensitive", # Case-insensitive search
|
||||
}
|
||||
|
||||
# Query users with pagination and filters
|
||||
users = await prisma_client.db.litellm_usertable.find_many(
|
||||
where=where_conditions,
|
||||
skip=skip,
|
||||
take=page_size,
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
if not users:
|
||||
return []
|
||||
|
||||
return users
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error searching users: {str(e)}")
|
||||
|
|
|
@ -1637,3 +1637,81 @@ def _set_team_metadata_field(
|
|||
_premium_user_check()
|
||||
team_data.metadata = team_data.metadata or {}
|
||||
team_data.metadata[field_name] = value
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/filter/ui",
|
||||
tags=["team management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
include_in_schema=False,
|
||||
responses={
|
||||
200: {"model": List[LiteLLM_TeamTable]},
|
||||
},
|
||||
)
|
||||
async def ui_view_teams(
|
||||
team_id: Optional[str] = fastapi.Query(
|
||||
default=None, description="Team ID in the request parameters"
|
||||
),
|
||||
team_alias: Optional[str] = fastapi.Query(
|
||||
default=None, description="Team alias in the request parameters"
|
||||
),
|
||||
page: int = fastapi.Query(
|
||||
default=1, description="Page number for pagination", ge=1
|
||||
),
|
||||
page_size: int = fastapi.Query(
|
||||
default=50, description="Number of items per page", ge=1, le=100
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
[PROXY-ADMIN ONLY] Filter teams based on partial match of team_id or team_alias with pagination.
|
||||
|
||||
Args:
|
||||
user_id (Optional[str]): Partial user ID to search for
|
||||
user_email (Optional[str]): Partial email to search for
|
||||
page (int): Page number for pagination (starts at 1)
|
||||
page_size (int): Number of items per page (max 100)
|
||||
user_api_key_dict (UserAPIKeyAuth): User authentication information
|
||||
|
||||
Returns:
|
||||
List[LiteLLM_SpendLogs]: Paginated list of matching user records
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
|
||||
try:
|
||||
# Calculate offset for pagination
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Build where conditions based on provided parameters
|
||||
where_conditions = {}
|
||||
|
||||
if team_id:
|
||||
where_conditions["team_id"] = {
|
||||
"contains": team_id,
|
||||
"mode": "insensitive", # Case-insensitive search
|
||||
}
|
||||
|
||||
if team_alias:
|
||||
where_conditions["team_alias"] = {
|
||||
"contains": team_alias,
|
||||
"mode": "insensitive", # Case-insensitive search
|
||||
}
|
||||
|
||||
# Query users with pagination and filters
|
||||
teams = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where=where_conditions,
|
||||
skip=skip,
|
||||
take=page_size,
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
if not teams:
|
||||
return []
|
||||
|
||||
return teams
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error searching teams: {str(e)}")
|
||||
|
|
|
@ -8,7 +8,7 @@ Has all /sso/* routes
|
|||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
@ -17,6 +17,7 @@ import litellm
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import MAX_SPENDLOG_ROWS_TO_QUERY
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
NewUserRequest,
|
||||
|
@ -27,6 +28,7 @@ from litellm.proxy._types import (
|
|||
TeamMemberAddRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import get_user_object
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
@ -368,7 +370,9 @@ async def create_team_member_add_task(team_id, user_info):
|
|||
)
|
||||
|
||||
|
||||
async def add_missing_team_member(user_info: NewUserResponse, sso_teams: List[str]):
|
||||
async def add_missing_team_member(
|
||||
user_info: Union[NewUserResponse, LiteLLM_UserTable], sso_teams: List[str]
|
||||
):
|
||||
"""
|
||||
- Get missing teams (diff b/w user_info.team_ids and sso_teams)
|
||||
- Add missing user to missing teams
|
||||
|
@ -414,7 +418,9 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
|||
master_key,
|
||||
premium_user,
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
ui_access_mode,
|
||||
user_api_key_cache,
|
||||
user_custom_sso,
|
||||
)
|
||||
|
||||
|
@ -557,20 +563,30 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
|||
user_role = None
|
||||
try:
|
||||
if prisma_client is not None:
|
||||
user_info = await prisma_client.get_data(user_id=user_id, table_name="user")
|
||||
try:
|
||||
user_info = await get_user_object(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
sso_user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(f"Error getting user object: {e}")
|
||||
user_info = None
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"user_info: {user_info}; litellm.default_internal_user_params: {litellm.default_internal_user_params}"
|
||||
)
|
||||
if user_info is None:
|
||||
## check if user-email in db ##
|
||||
user_info = await prisma_client.db.litellm_usertable.find_first(
|
||||
where={"user_email": user_email}
|
||||
)
|
||||
|
||||
if user_info is not None and user_id is not None:
|
||||
if user_info is not None:
|
||||
user_id = user_info.user_id
|
||||
user_defined_values = SSOUserDefinedValues(
|
||||
models=getattr(user_info, "models", user_id_models),
|
||||
user_id=user_id,
|
||||
user_id=user_info.user_id,
|
||||
user_email=getattr(user_info, "user_email", user_email),
|
||||
user_role=getattr(user_info, "user_role", None),
|
||||
max_budget=getattr(
|
||||
|
@ -588,6 +604,9 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
|||
where={"user_email": user_email}, data={"user_id": user_id} # type: ignore
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.info(
|
||||
"user not in DB, inserting user into LiteLLM DB"
|
||||
)
|
||||
# user not in DB, insert User into LiteLLM DB
|
||||
user_info = await insert_sso_user(
|
||||
result_openid=result,
|
||||
|
@ -624,9 +643,6 @@ async def auth_callback(request: Request): # noqa: PLR0915
|
|||
key = response["token"] # type: ignore
|
||||
user_id = response["user_id"] # type: ignore
|
||||
|
||||
# This should always be true
|
||||
# User_id on SSO == user_id in the LiteLLM_VerificationToken Table
|
||||
assert user_id == _user_id_from_sso
|
||||
litellm_dashboard_ui = "/ui/"
|
||||
user_role = user_role or LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
|
||||
if (
|
||||
|
|
|
@ -24,6 +24,7 @@ import pytest
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model", ["claude-2", "anthropic/claude-2"])
|
||||
@pytest.mark.flaky(retries=6, delay=1)
|
||||
async def test_acompletion_claude2(model):
|
||||
try:
|
||||
litellm.set_verbose = True
|
||||
|
|
|
@ -327,6 +327,7 @@ class TestLangfuseLogging:
|
|||
({}, "empty_metadata.json"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.flaky(retries=6, delay=1)
|
||||
async def test_langfuse_logging_with_various_metadata_types(
|
||||
self, mock_setup, test_metadata, response_json_file
|
||||
):
|
||||
|
|
|
@ -61,6 +61,9 @@ async def test_auth_callback_new_user(mock_google_sso, mock_env_vars, prisma_cli
|
|||
Tests that a new SSO Sign In user is by default given an 'INTERNAL_USER_VIEW_ONLY' role
|
||||
"""
|
||||
import uuid
|
||||
import litellm
|
||||
|
||||
litellm._turn_on_debug()
|
||||
|
||||
# Generate a unique user ID
|
||||
unique_user_id = str(uuid.uuid4())
|
||||
|
|
7
ui/litellm-dashboard/package-lock.json
generated
7
ui/litellm-dashboard/package-lock.json
generated
|
@ -30,6 +30,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
|
@ -944,6 +945,12 @@
|
|||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz",
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Form, Button, Select } from 'antd';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { userFilterUICall } from "@/components/networking";
|
||||
|
||||
interface User {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
label: string;
|
||||
value: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
user_email: string;
|
||||
user_id: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
interface UserSearchModalProps {
|
||||
isVisible: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const UserSearchModal: React.FC<UserSearchModalProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
accessToken
|
||||
}) => {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [selectedField, setSelectedField] = useState<'user_email' | 'user_id'>('user_email');
|
||||
|
||||
const fetchUsers = async (searchText: string, fieldName: 'user_email' | 'user_id'): Promise<void> => {
|
||||
if (!searchText) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append(fieldName, searchText);
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
const response = await userFilterUICall(accessToken, params);
|
||||
|
||||
const data: User[] = response
|
||||
const options: UserOption[] = data.map(user => ({
|
||||
label: fieldName === 'user_email'
|
||||
? `${user.user_email}`
|
||||
: `${user.user_id}`,
|
||||
value: fieldName === 'user_email' ? user.user_email : user.user_id,
|
||||
user
|
||||
}));
|
||||
setUserOptions(options);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((text: string, fieldName: 'user_email' | 'user_id') => fetchUsers(text, fieldName), 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearch = (value: string, fieldName: 'user_email' | 'user_id'): void => {
|
||||
setSelectedField(fieldName);
|
||||
debouncedSearch(value, fieldName);
|
||||
};
|
||||
|
||||
const handleSelect = (_value: string, option: UserOption): void => {
|
||||
const selectedUser = option.user;
|
||||
form.setFieldsValue({
|
||||
user_email: selectedUser.user_email,
|
||||
user_id: selectedUser.user_id,
|
||||
role: form.getFieldValue('role') // Preserve current role selection
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
form.resetFields();
|
||||
setUserOptions([]);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add Team Member"
|
||||
open={isVisible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Form<FormValues>
|
||||
form={form}
|
||||
onFinish={onSubmit}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
initialValues={{
|
||||
role: "user",
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="user_email"
|
||||
className="mb-4"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
className="w-full"
|
||||
placeholder="Search by email"
|
||||
filterOption={false}
|
||||
onSearch={(value) => handleSearch(value, 'user_email')}
|
||||
onSelect={(value, option) => handleSelect(value, option as UserOption)}
|
||||
options={selectedField === 'user_email' ? userOptions : []}
|
||||
loading={loading}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="text-center mb-4">OR</div>
|
||||
|
||||
<Form.Item
|
||||
label="User ID"
|
||||
name="user_id"
|
||||
className="mb-4"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
className="w-full"
|
||||
placeholder="Search by user ID"
|
||||
filterOption={false}
|
||||
onSearch={(value) => handleSearch(value, 'user_id')}
|
||||
onSelect={(value, option) => handleSelect(value, option as UserOption)}
|
||||
options={selectedField === 'user_id' ? userOptions : []}
|
||||
loading={loading}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Member Role"
|
||||
name="role"
|
||||
className="mb-4"
|
||||
>
|
||||
<Select defaultValue="user">
|
||||
<Select.Option value="admin">admin</Select.Option>
|
||||
<Select.Option value="user">user</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<div className="text-right mt-4">
|
||||
<Button type="primary" htmlType="submit">
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSearchModal;
|
|
@ -1510,6 +1510,36 @@ export const allEndUsersCall = async (accessToken: String) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const userFilterUICall = async (accessToken: String, params: URLSearchParams) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`;
|
||||
|
||||
if (params.get("user_email")) {
|
||||
url += `?user_email=${params.get("user_email")}`;
|
||||
}
|
||||
if (params.get("user_id")) {
|
||||
url += `?user_id=${params.get("user_id")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to create key:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const userSpendLogsCall = async (
|
||||
accessToken: String,
|
||||
token: String,
|
||||
|
@ -2402,6 +2432,47 @@ export const teamMemberUpdateCall = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const teamMemberDeleteCall = async (
|
||||
accessToken: string,
|
||||
teamId: string,
|
||||
formValues: Member // Assuming formValues is an object
|
||||
) => {
|
||||
try {
|
||||
console.log("Form Values in teamMemberAddCall:", formValues); // Log the form values before making the API call
|
||||
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/team/member_delete`
|
||||
: `/team/member_delete`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
team_id: teamId,
|
||||
...(formValues.user_email && { user_email: formValues.user_email }),
|
||||
...(formValues.user_id && { user_id: formValues.user_id })
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("API Response:", data);
|
||||
return data;
|
||||
// Handle success - you might want to update some state or UI based on the created key
|
||||
} catch (error) {
|
||||
console.error("Failed to create key:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const organizationMemberAddCall = async (
|
||||
accessToken: string,
|
||||
organizationId: string,
|
||||
|
|
|
@ -1,19 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Select as AntSelect, Button as AntButton, message } from 'antd';
|
||||
import { Select, SelectItem } from "@tremor/react";
|
||||
import { Card, Text } from "@tremor/react";
|
||||
|
||||
export interface TeamMember {
|
||||
id?: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
import { Member } from "@/components/networking";
|
||||
interface TeamMemberModalProps {
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: TeamMember) => void;
|
||||
initialData?: TeamMember | null;
|
||||
onSubmit: (data: Member) => void;
|
||||
initialData?: Member | null;
|
||||
mode: 'add' | 'edit';
|
||||
}
|
||||
|
||||
|
@ -26,11 +20,22 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
form.setFieldsValue({
|
||||
user_email: initialData.user_email,
|
||||
user_id: initialData.user_id,
|
||||
role: initialData.role,
|
||||
});
|
||||
}
|
||||
}, [initialData, form]);
|
||||
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
const formData: TeamMember = {
|
||||
email: values.user_email,
|
||||
id: values.user_id,
|
||||
const formData: Member = {
|
||||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
};
|
||||
|
||||
|
@ -60,8 +65,8 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
|
|||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
initialValues={{
|
||||
user_email: initialData?.email?.trim() || '',
|
||||
user_id: initialData?.id?.trim() || '',
|
||||
user_email: initialData?.user_email?.trim() || '',
|
||||
user_id: initialData?.user_id?.trim() || '',
|
||||
role: initialData?.role || 'user',
|
||||
}}
|
||||
>
|
||||
|
|
326
ui/litellm-dashboard/src/components/team/team_info.tsx
Normal file
326
ui/litellm-dashboard/src/components/team/team_info.tsx
Normal file
|
@ -0,0 +1,326 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Tab,
|
||||
TabList,
|
||||
TabGroup,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Grid,
|
||||
Badge,
|
||||
Button as TremorButton,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
Table,
|
||||
Icon
|
||||
} from "@tremor/react";
|
||||
import { teamInfoCall, teamMemberDeleteCall, teamMemberAddCall, teamMemberUpdateCall, Member } from "@/components/networking";
|
||||
import { Button, Modal, Form, Input, Select as AntSelect, message } from "antd";
|
||||
import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import TeamMemberModal from "./edit_membership";
|
||||
import UserSearchModal from "@/components/common_components/user_search_modal";
|
||||
interface TeamData {
|
||||
team_id: string;
|
||||
team_info: {
|
||||
team_alias: string;
|
||||
team_id: string;
|
||||
organization_id: string | null;
|
||||
admins: string[];
|
||||
members: string[];
|
||||
members_with_roles: Member[];
|
||||
metadata: Record<string, any>;
|
||||
tpm_limit: number | null;
|
||||
rpm_limit: number | null;
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
models: string[];
|
||||
blocked: boolean;
|
||||
spend: number;
|
||||
max_parallel_requests: number | null;
|
||||
budget_reset_at: string | null;
|
||||
model_id: string | null;
|
||||
litellm_model_table: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
keys: any[];
|
||||
team_memberships: any[];
|
||||
}
|
||||
|
||||
interface TeamInfoProps {
|
||||
teamId: string;
|
||||
onClose: () => void;
|
||||
accessToken: string | null;
|
||||
is_team_admin: boolean;
|
||||
is_proxy_admin: boolean;
|
||||
}
|
||||
|
||||
const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
teamId,
|
||||
onClose,
|
||||
accessToken,
|
||||
is_team_admin,
|
||||
is_proxy_admin
|
||||
}) => {
|
||||
const [teamData, setTeamData] = useState<TeamData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
|
||||
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
|
||||
|
||||
const canManageMembers = is_team_admin || is_proxy_admin;
|
||||
|
||||
const fetchTeamInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!accessToken) return;
|
||||
const response = await teamInfoCall(accessToken, teamId);
|
||||
setTeamData(response);
|
||||
} catch (error) {
|
||||
message.error("Failed to load team information");
|
||||
console.error("Error fetching team info:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamInfo();
|
||||
}, [teamId, accessToken]);
|
||||
|
||||
|
||||
const handleMemberCreate = async (values: any) => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member: Member = {
|
||||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
}
|
||||
const response = await teamMemberAddCall(accessToken, teamId, member);
|
||||
|
||||
|
||||
message.success("Team member added successfully");
|
||||
setIsAddMemberModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchTeamInfo();
|
||||
} catch (error) {
|
||||
message.error("Failed to add team member");
|
||||
console.error("Error adding team member:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberDelete = async (member: Member) => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await teamMemberDeleteCall(accessToken, teamId, member);
|
||||
|
||||
message.success("Team member removed successfully");
|
||||
fetchTeamInfo();
|
||||
} catch (error) {
|
||||
message.error("Failed to remove team member");
|
||||
console.error("Error removing team member:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberUpdate = async (values: any) => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const member: Member = {
|
||||
user_email: values.user_email,
|
||||
user_id: values.user_id,
|
||||
role: values.role,
|
||||
}
|
||||
|
||||
const response = await teamMemberUpdateCall(accessToken, teamId, member);
|
||||
|
||||
message.success("Team member updated successfully");
|
||||
setIsEditMemberModalVisible(false);
|
||||
fetchTeamInfo();
|
||||
} catch (error) {
|
||||
message.error("Failed to update team member");
|
||||
console.error("Error updating team member:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!teamData?.team_info) {
|
||||
return <div className="p-4">Team not found</div>;
|
||||
}
|
||||
|
||||
const { team_info: info } = teamData;
|
||||
|
||||
const renderMembersPanel = () => (
|
||||
<div className="space-y-4">
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{teamData
|
||||
? teamData.team_info.members_with_roles.map(
|
||||
(member: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member["user_id"]}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member["user_email"]}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member["role"]}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{is_team_admin ? (
|
||||
<>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedEditMember(member);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
onClick={() => {handleMemberDelete(member)}}
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<TremorButton onClick={() => setIsAddMemberModalVisible(true)}>Add Member</TremorButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button onClick={onClose} className="mb-4">← Back</Button>
|
||||
<Title>{info.team_alias}</Title>
|
||||
<Text className="text-gray-500 font-mono">{info.team_id}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabGroup>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Overview</Tab>
|
||||
<Tab>Members</Tab>
|
||||
<Tab>Settings</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||
<Card>
|
||||
<Text>Budget Status</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${info.spend.toFixed(6)}</Title>
|
||||
<Text>of {info.max_budget === null ? "Unlimited" : `$${info.max_budget}`}</Text>
|
||||
{info.budget_duration && (
|
||||
<Text className="text-gray-500">Reset: {info.budget_duration}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Rate Limits</Text>
|
||||
<div className="mt-2">
|
||||
<Text>TPM: {info.tpm_limit || 'Unlimited'}</Text>
|
||||
<Text>RPM: {info.rpm_limit || 'Unlimited'}</Text>
|
||||
{info.max_parallel_requests && (
|
||||
<Text>Max Parallel Requests: {info.max_parallel_requests}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Models</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{info.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{renderMembersPanel()}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Title>Team Settings</Title>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Team ID</Text>
|
||||
<Text className="font-mono">{info.team_id}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Created At</Text>
|
||||
<Text>{new Date(info.created_at).toLocaleString()}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Status</Text>
|
||||
<Badge color={info.blocked ? 'red' : 'green'}>
|
||||
{info.blocked ? 'Blocked' : 'Active'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<TeamMemberModal
|
||||
visible={isEditMemberModalVisible}
|
||||
onCancel={() => setIsEditMemberModalVisible(false)}
|
||||
onSubmit={handleMemberUpdate}
|
||||
initialData={selectedEditMember}
|
||||
mode="edit"
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
<UserSearchModal
|
||||
isVisible={isAddMemberModalVisible}
|
||||
onCancel={() => setIsAddMemberModalVisible(false)}
|
||||
onSubmit={handleMemberCreate}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamInfoView;
|
|
@ -25,7 +25,7 @@ import { fetchAvailableModelsForTeamOrKey, getModelDisplayName } from "./key_tea
|
|||
import { Select, SelectItem } from "@tremor/react";
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { getGuardrailsList } from "./networking";
|
||||
|
||||
import TeamInfoView from "@/components/team/team_info";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
@ -129,8 +129,9 @@ const Team: React.FC<TeamProps> = ({
|
|||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState<null | any>(
|
||||
teams ? teams[0] : null
|
||||
null
|
||||
);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
const [isTeamModalVisible, setIsTeamModalVisible] = useState(false);
|
||||
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
|
||||
|
@ -500,6 +501,9 @@ const Team: React.FC<TeamProps> = ({
|
|||
};
|
||||
|
||||
const is_team_admin = (team: any) => {
|
||||
if (team == null || team.members_with_roles == null) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < team.members_with_roles.length; i++) {
|
||||
let member = team.members_with_roles[i];
|
||||
if (member.user_id == userID && member.role == "admin") {
|
||||
|
@ -509,58 +513,7 @@ const Team: React.FC<TeamProps> = ({
|
|||
return false;
|
||||
}
|
||||
|
||||
const _common_member_update_call = async (formValues: Record<string, any>, callType: "add" | "edit") => {
|
||||
try {
|
||||
if (accessToken != null && teams != null) {
|
||||
message.info("Adding Member");
|
||||
const user_role: Member = {
|
||||
role: formValues.role,
|
||||
user_email: formValues.user_email,
|
||||
user_id: formValues.user_id,
|
||||
};
|
||||
let response: any;
|
||||
if (callType == "add") {
|
||||
response = await teamMemberAddCall(
|
||||
accessToken,
|
||||
selectedTeam["team_id"],
|
||||
user_role
|
||||
);
|
||||
message.success("Member added");
|
||||
} else {
|
||||
response = await teamMemberUpdateCall(
|
||||
accessToken,
|
||||
selectedTeam["team_id"],
|
||||
{
|
||||
"role": formValues.role,
|
||||
"user_id": formValues.id,
|
||||
"user_email": formValues.email
|
||||
}
|
||||
);
|
||||
message.success("Member updated");
|
||||
}
|
||||
|
||||
// Checking if the team exists in the list and updating or adding accordingly
|
||||
const foundIndex = teams.findIndex((team) => {
|
||||
console.log(
|
||||
`team.team_id=${team.team_id}; response.data.team_id=${response.data.team_id}`
|
||||
);
|
||||
return team.team_id === response.data.team_id;
|
||||
});
|
||||
console.log(`foundIndex: ${foundIndex}`);
|
||||
if (foundIndex !== -1) {
|
||||
// If the team is found, update it
|
||||
const updatedTeams = [...teams]; // Copy the current state
|
||||
updatedTeams[foundIndex] = response.data; // Update the specific team
|
||||
setTeams(updatedTeams); // Set the new state
|
||||
setSelectedTeam(response.data);
|
||||
}
|
||||
setIsAddMemberModalVisible(false);
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating the team:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
// Update the 'lastRefreshed' state to the current date and time
|
||||
|
@ -577,6 +530,15 @@ const Team: React.FC<TeamProps> = ({
|
|||
}
|
||||
return (
|
||||
<div className="w-full mx-4">
|
||||
{selectedTeamId ? (
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
onClose={() => setSelectedTeamId(null)}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
|
||||
is_proxy_admin={userRole == "Admin"}
|
||||
/>
|
||||
) : (
|
||||
<TabGroup className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">
|
||||
|
@ -596,6 +558,9 @@ const Team: React.FC<TeamProps> = ({
|
|||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Text>
|
||||
Click on "Team ID" to view team details <b>and</b> manage team members.
|
||||
</Text>
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||
|
@ -628,19 +593,27 @@ const Team: React.FC<TeamProps> = ({
|
|||
>
|
||||
{team["team_alias"]}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontSize: "0.75em", // or any smaller size as needed
|
||||
}}
|
||||
>
|
||||
<Tooltip title={team.team_id}>
|
||||
{team.team_id}
|
||||
</Tooltip>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={team.team_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
|
||||
onClick={() => {
|
||||
// Add click handler
|
||||
setSelectedTeamId(team.team_id);
|
||||
}}
|
||||
>
|
||||
{team.team_id.slice(0, 7)}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableCell
|
||||
style={{
|
||||
maxWidth: "4px",
|
||||
|
@ -964,25 +937,30 @@ const Team: React.FC<TeamProps> = ({
|
|||
</Modal>
|
||||
</Col>
|
||||
) : null}
|
||||
<Col numColSpan={1}>
|
||||
{/* <Col numColSpan={1}>
|
||||
<Title level={4}>Team Members</Title>
|
||||
<Paragraph>
|
||||
If you belong to multiple teams, this setting controls which teams
|
||||
members you see.
|
||||
If you belong to multiple teams, this setting controls which teams' members you see.
|
||||
</Paragraph>
|
||||
{teams && teams.length > 0 ? (
|
||||
<Select defaultValue="0">
|
||||
{teams.map((team: any, index) => (
|
||||
<SelectItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
onClick={() => {
|
||||
setSelectedTeam(team);
|
||||
}}
|
||||
>
|
||||
{team["team_alias"]}
|
||||
</SelectItem>
|
||||
))}
|
||||
{[...teams]
|
||||
.sort((a, b) => {
|
||||
const aliasA = a.team_alias || '';
|
||||
const aliasB = b.team_alias || '';
|
||||
return aliasA.localeCompare(aliasB);
|
||||
})
|
||||
.map((team: any, index) => (
|
||||
<SelectItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
onClick={() => {
|
||||
setSelectedTeam(team);
|
||||
}}
|
||||
>
|
||||
{team.team_alias || 'Unnamed Team'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Paragraph>
|
||||
|
@ -1112,7 +1090,7 @@ const Team: React.FC<TeamProps> = ({
|
|||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Col>
|
||||
</Col> */}
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
|
@ -1123,7 +1101,7 @@ const Team: React.FC<TeamProps> = ({
|
|||
</TabPanel>
|
||||
</TabPanels>
|
||||
|
||||
</TabGroup>
|
||||
</TabGroup>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue