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:
Krish Dholakia 2025-02-02 23:02:33 -08:00 committed by GitHub
parent 8900b18504
commit 65d3f85a69
14 changed files with 862 additions and 111 deletions

View file

@ -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(

View file

@ -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)}")

View file

@ -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)}")

View file

@ -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 (

View file

@ -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

View file

@ -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
):

View 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())

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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,

View file

@ -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',
}}
>

View 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;

View file

@ -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>
);
};