mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-17 20:18:10 +00:00
Merge pull request #21745 from BerriAI/litellm_org_member_email_ui
[Feature] UI - Organization Info: Show member email, AntD tabs, reusable MemberTable
This commit is contained in:
@@ -2437,9 +2437,19 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase):
|
||||
Any
|
||||
] = None # You might want to replace 'Any' with a more specific type if available
|
||||
litellm_budget_table: Optional[LiteLLM_BudgetTable] = None
|
||||
user_email: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
@model_validator(mode="after")
|
||||
def populate_user_email(self) -> "LiteLLM_OrganizationMembershipTable":
|
||||
if self.user_email is None and self.user is not None:
|
||||
if isinstance(self.user, dict):
|
||||
self.user_email = self.user.get("user_email")
|
||||
else:
|
||||
self.user_email = getattr(self.user, "user_email", None)
|
||||
return self
|
||||
|
||||
|
||||
class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable):
|
||||
"""Represents user-controllable params for a LiteLLM_OrganizationTable record"""
|
||||
|
||||
@@ -721,7 +721,11 @@ async def info_organization(organization_id: str):
|
||||
where={"organization_id": organization_id},
|
||||
include={
|
||||
"litellm_budget_table": True,
|
||||
"members": True,
|
||||
"members": {
|
||||
"include": {
|
||||
"user": True,
|
||||
}
|
||||
},
|
||||
"teams": True,
|
||||
"object_permission": True,
|
||||
},
|
||||
|
||||
@@ -535,3 +535,28 @@ async def test_list_organization_filter_by_org_alias(monkeypatch):
|
||||
"members": True,
|
||||
"teams": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organization_info_includes_user_email(monkeypatch):
|
||||
"""
|
||||
Test that GET /organization/info returns user_email in members list.
|
||||
"""
|
||||
from litellm.proxy._types import LiteLLM_OrganizationMembershipTable
|
||||
from datetime import datetime
|
||||
|
||||
# Simulate a membership row with a nested user object that has user_email
|
||||
raw_membership = {
|
||||
"user_id": "user_abc",
|
||||
"organization_id": "org_xyz",
|
||||
"user_role": "org_admin",
|
||||
"spend": 0.0,
|
||||
"budget_id": None,
|
||||
"created_at": datetime.utcnow(),
|
||||
"updated_at": datetime.utcnow(),
|
||||
"user": {"user_email": "alice@example.com"},
|
||||
"litellm_budget_table": None,
|
||||
}
|
||||
|
||||
membership = LiteLLM_OrganizationMembershipTable(**raw_membership)
|
||||
assert membership.user_email == "alice@example.com"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Member } from "@/components/networking";
|
||||
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import React from "react";
|
||||
import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export interface MemberTableProps {
|
||||
members: Member[];
|
||||
canEdit: boolean;
|
||||
onEdit: (member: Member) => void;
|
||||
onDelete: (member: Member) => void;
|
||||
onAddMember?: () => void;
|
||||
roleColumnTitle?: string;
|
||||
roleTooltip?: string;
|
||||
extraColumns?: ColumnsType<Member>;
|
||||
showDeleteForMember?: (member: Member) => boolean;
|
||||
}
|
||||
|
||||
export default function MemberTable({
|
||||
members,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddMember,
|
||||
roleColumnTitle = "Role",
|
||||
roleTooltip,
|
||||
extraColumns = [],
|
||||
showDeleteForMember,
|
||||
}: MemberTableProps) {
|
||||
const baseColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "User Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
render: (email: string | null) => <Text>{email || "-"}</Text>,
|
||||
},
|
||||
{
|
||||
title: "User ID",
|
||||
dataIndex: "user_id",
|
||||
key: "user_id",
|
||||
render: (userId: string | null) =>
|
||||
userId === "default_user_id" ? (
|
||||
<Tag color="blue">Default Proxy Admin</Tag>
|
||||
) : (
|
||||
<Text>{userId || "-"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: roleTooltip ? (
|
||||
<Space direction="horizontal">
|
||||
{roleColumnTitle}
|
||||
<Tooltip title={roleTooltip}>
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
) : (
|
||||
roleColumnTitle
|
||||
),
|
||||
dataIndex: "role",
|
||||
key: "role",
|
||||
render: (role: string) => (
|
||||
<Space>
|
||||
{role?.toLowerCase() === "admin" || role?.toLowerCase() === "org_admin" ? (
|
||||
<CrownOutlined />
|
||||
) : (
|
||||
<UserOutlined />
|
||||
)}
|
||||
<Text style={{ textTransform: "capitalize" }}>{role || "-"}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
...extraColumns,
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
fixed: "right" as const,
|
||||
width: 120,
|
||||
render: (_: unknown, record: Member) =>
|
||||
canEdit ? (
|
||||
<Space>
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
{(!showDeleteForMember || showDeleteForMember(record)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Table
|
||||
columns={baseColumns}
|
||||
dataSource={members}
|
||||
rowKey={(record) => record.user_id ?? record.user_email ?? JSON.stringify(record)}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: "max-content" }}
|
||||
/>
|
||||
{onAddMember && canEdit && (
|
||||
<Button icon={<UserAddOutlined />} type="primary" onClick={onAddMember}>
|
||||
Add Member
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,21 @@
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { createTeamAliasMap } from "@/utils/teamUtils";
|
||||
import { ArrowLeftIcon, PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Grid,
|
||||
Icon,
|
||||
Tab,
|
||||
TabGroup,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Button as TremorButton,
|
||||
} from "@tremor/react";
|
||||
import { Button, Form, Input, Select } from "antd";
|
||||
import { Button, Form, Input, Select, Tabs, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import MemberTable from "../common_components/MemberTable";
|
||||
import UserSearchModal from "../common_components/user_search_modal";
|
||||
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
|
||||
import { ModelSelect } from "../ModelSelect/ModelSelect";
|
||||
@@ -224,6 +214,41 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const orgExtraColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "Spend (USD)",
|
||||
key: "spend",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const orgMember =
|
||||
record.user_id != null
|
||||
? (orgData.members || []).find((m) => m.user_id === record.user_id)
|
||||
: undefined;
|
||||
return (
|
||||
<Typography.Text>
|
||||
${formatNumberWithCommas(orgMember?.spend ?? 0, 4)}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Created At",
|
||||
key: "created_at",
|
||||
render: (_: unknown, record: Member) => {
|
||||
const orgMember =
|
||||
record.user_id != null
|
||||
? (orgData.members || []).find((m) => m.user_id === record.user_id)
|
||||
: undefined;
|
||||
return (
|
||||
<Typography.Text>
|
||||
{orgMember?.created_at
|
||||
? new Date(orgMember.created_at).toLocaleString()
|
||||
: "-"}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -248,325 +273,271 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabGroup defaultIndex={editOrg ? 2 : 0}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Overview</Tab>
|
||||
<Tab>Members</Tab>
|
||||
<Tab>Settings</Tab>
|
||||
</TabList>
|
||||
<Tabs
|
||||
defaultActiveKey={editOrg ? "settings" : "overview"}
|
||||
className="mb-4"
|
||||
items={[
|
||||
{
|
||||
key: "overview",
|
||||
label: "Overview",
|
||||
children: (
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||
<Card>
|
||||
<Text>Organization Details</Text>
|
||||
<div className="mt-2">
|
||||
<Text>Created: {new Date(orgData.created_at).toLocaleDateString()}</Text>
|
||||
<Text>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</Text>
|
||||
<Text>Created By: {orgData.created_by}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<TabPanels>
|
||||
{/* Overview Panel */}
|
||||
<TabPanel>
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||
<Card>
|
||||
<Text>Organization Details</Text>
|
||||
<div className="mt-2">
|
||||
<Text>Created: {new Date(orgData.created_at).toLocaleDateString()}</Text>
|
||||
<Text>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</Text>
|
||||
<Text>Created By: {orgData.created_by}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Budget Status</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(orgData.spend, 4)}</Title>
|
||||
<Text>
|
||||
of{" "}
|
||||
{orgData.litellm_budget_table.max_budget === null
|
||||
? "Unlimited"
|
||||
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
|
||||
</Text>
|
||||
{orgData.litellm_budget_table.budget_duration && (
|
||||
<Text className="text-gray-500">Reset: {orgData.litellm_budget_table.budget_duration}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Rate Limits</Text>
|
||||
<div className="mt-2">
|
||||
<Text>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</Text>
|
||||
<Text>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</Text>
|
||||
{orgData.litellm_budget_table.max_parallel_requests && (
|
||||
<Text>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Models</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.models.length === 0 ? (
|
||||
<Badge color="red">All proxy models</Badge>
|
||||
) : (
|
||||
orgData.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Teams</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.teams?.map((team, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="card"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<div className="space-y-4">
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[75vh]">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>Spend</TableHeaderCell>
|
||||
<TableHeaderCell>Created At</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{orgData.members && orgData.members.length > 0 ? (
|
||||
orgData.members.map((member, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_id}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text className="font-mono">{member.user_role}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>${formatNumberWithCommas(member.spend, 4)}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{new Date(member.created_at).toLocaleString()}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditOrg && (
|
||||
<>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedEditMember({
|
||||
role: member.user_role,
|
||||
user_email: member.user_email,
|
||||
user_id: member.user_id,
|
||||
});
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleMemberDelete(member);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
<Text className="text-gray-500">No members found</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Card>
|
||||
<Text>Budget Status</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(orgData.spend, 4)}</Title>
|
||||
<Text>
|
||||
of{" "}
|
||||
{orgData.litellm_budget_table.max_budget === null
|
||||
? "Unlimited"
|
||||
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
|
||||
</Text>
|
||||
{orgData.litellm_budget_table.budget_duration && (
|
||||
<Text className="text-gray-500">Reset: {orgData.litellm_budget_table.budget_duration}</Text>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
{canEditOrg && (
|
||||
<TremorButton
|
||||
onClick={() => {
|
||||
setIsAddMemberModalVisible(true);
|
||||
}}
|
||||
>
|
||||
Add Member
|
||||
</TremorButton>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<TabPanel>
|
||||
<Card className="overflow-y-auto max-h-[65vh]">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Organization Settings</Title>
|
||||
{canEditOrg && !isEditing && (
|
||||
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleOrgUpdate}
|
||||
initialValues={{
|
||||
organization_alias: orgData.organization_alias,
|
||||
models: orgData.models,
|
||||
tpm_limit: orgData.litellm_budget_table.tpm_limit,
|
||||
rpm_limit: orgData.litellm_budget_table.rpm_limit,
|
||||
max_budget: orgData.litellm_budget_table.max_budget,
|
||||
budget_duration: orgData.litellm_budget_table.budget_duration,
|
||||
metadata: orgData.metadata ? JSON.stringify(orgData.metadata, null, 2) : "",
|
||||
vector_stores: orgData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: orgData.object_permission?.mcp_servers || [],
|
||||
accessGroups: orgData.object_permission?.mcp_access_groups || [],
|
||||
},
|
||||
}}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="Organization Name"
|
||||
name="organization_alias"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input an organization name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Models" name="models">
|
||||
<ModelSelect
|
||||
value={form.getFieldValue("models")}
|
||||
onChange={(values) => form.setFieldValue("models", values)}
|
||||
context="organization"
|
||||
options={{
|
||||
includeSpecialOptions: true,
|
||||
showAllProxyModelsOverride: true,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Reset Budget" name="budget_duration">
|
||||
<Select placeholder="n/a">
|
||||
<Select.Option value="24h">daily</Select.Option>
|
||||
<Select.Option value="7d">weekly</Select.Option>
|
||||
<Select.Option value="30d">monthly</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
|
||||
<NumericalInput step={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Vector Stores" name="vector_stores">
|
||||
<VectorStoreSelector
|
||||
onChange={(values) => form.setFieldValue("vector_stores", values)}
|
||||
value={form.getFieldValue("vector_stores")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select vector stores"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="MCP Servers & Access Groups" name="mcp_servers_and_groups">
|
||||
<MCPServerSelector
|
||||
onChange={(values) => form.setFieldValue("mcp_servers_and_groups", values)}
|
||||
value={form.getFieldValue("mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers and access groups"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="sticky z-10 bg-white p-4 border-t border-gray-200 bottom-[-1.5rem] inset-x-[-1.5rem]">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<TremorButton variant="secondary" onClick={() => setIsEditing(false)} disabled={isOrgSaving}>
|
||||
Cancel
|
||||
</TremorButton>
|
||||
<TremorButton type="submit" loading={isOrgSaving}>
|
||||
Save Changes
|
||||
</TremorButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Organization Name</Text>
|
||||
<div>{orgData.organization_alias}</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Rate Limits</Text>
|
||||
<div className="mt-2">
|
||||
<Text>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</Text>
|
||||
<Text>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</Text>
|
||||
{orgData.litellm_budget_table.max_parallel_requests && (
|
||||
<Text>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Organization ID</Text>
|
||||
<div className="font-mono">{orgData.organization_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Created At</Text>
|
||||
<div>{new Date(orgData.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{orgData.models.map((model, index) => (
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Models</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.models.length === 0 ? (
|
||||
<Badge color="red">All proxy models</Badge>
|
||||
) : (
|
||||
orgData.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Rate Limits</Text>
|
||||
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
|
||||
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Budget</Text>
|
||||
<div>
|
||||
Max:{" "}
|
||||
{orgData.litellm_budget_table.max_budget !== null
|
||||
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
|
||||
: "No Limit"}
|
||||
</div>
|
||||
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Teams</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{orgData.teams?.map((team, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{teamAliasMap[team.team_id] || team.team_id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="card"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
children: (
|
||||
<div className="space-y-4">
|
||||
<MemberTable
|
||||
members={(orgData.members || []).map((m) => ({
|
||||
role: m.user_role || "",
|
||||
user_id: m.user_id,
|
||||
user_email: m.user_email,
|
||||
}))}
|
||||
canEdit={canEditOrg}
|
||||
onEdit={(member) => {
|
||||
setSelectedEditMember(member);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={(member) => handleMemberDelete(member)}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Organization Role"
|
||||
extraColumns={orgExtraColumns}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: "Settings",
|
||||
children: (
|
||||
<Card className="overflow-y-auto max-h-[65vh]">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Organization Settings</Title>
|
||||
{canEditOrg && !isEditing && (
|
||||
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{isEditing ? (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleOrgUpdate}
|
||||
initialValues={{
|
||||
organization_alias: orgData.organization_alias,
|
||||
models: orgData.models,
|
||||
tpm_limit: orgData.litellm_budget_table.tpm_limit,
|
||||
rpm_limit: orgData.litellm_budget_table.rpm_limit,
|
||||
max_budget: orgData.litellm_budget_table.max_budget,
|
||||
budget_duration: orgData.litellm_budget_table.budget_duration,
|
||||
metadata: orgData.metadata ? JSON.stringify(orgData.metadata, null, 2) : "",
|
||||
vector_stores: orgData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: orgData.object_permission?.mcp_servers || [],
|
||||
accessGroups: orgData.object_permission?.mcp_access_groups || [],
|
||||
},
|
||||
}}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="Organization Name"
|
||||
name="organization_alias"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input an organization name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Models" name="models">
|
||||
<ModelSelect
|
||||
value={form.getFieldValue("models")}
|
||||
onChange={(values) => form.setFieldValue("models", values)}
|
||||
context="organization"
|
||||
options={{
|
||||
includeSpecialOptions: true,
|
||||
showAllProxyModelsOverride: true,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Reset Budget" name="budget_duration">
|
||||
<Select placeholder="n/a">
|
||||
<Select.Option value="24h">daily</Select.Option>
|
||||
<Select.Option value="7d">weekly</Select.Option>
|
||||
<Select.Option value="30d">monthly</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
|
||||
<NumericalInput step={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
|
||||
<NumericalInput step={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Vector Stores" name="vector_stores">
|
||||
<VectorStoreSelector
|
||||
onChange={(values) => form.setFieldValue("vector_stores", values)}
|
||||
value={form.getFieldValue("vector_stores")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select vector stores"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="MCP Servers & Access Groups" name="mcp_servers_and_groups">
|
||||
<MCPServerSelector
|
||||
onChange={(values) => form.setFieldValue("mcp_servers_and_groups", values)}
|
||||
value={form.getFieldValue("mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers and access groups"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="sticky z-10 bg-white p-4 border-t border-gray-200 bottom-[-1.5rem] inset-x-[-1.5rem]">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<TremorButton variant="secondary" onClick={() => setIsEditing(false)} disabled={isOrgSaving}>
|
||||
Cancel
|
||||
</TremorButton>
|
||||
<TremorButton type="submit" loading={isOrgSaving}>
|
||||
Save Changes
|
||||
</TremorButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Organization Name</Text>
|
||||
<div>{orgData.organization_alias}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Organization ID</Text>
|
||||
<div className="font-mono">{orgData.organization_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Created At</Text>
|
||||
<div>{new Date(orgData.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{orgData.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Rate Limits</Text>
|
||||
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
|
||||
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Budget</Text>
|
||||
<div>
|
||||
Max:{" "}
|
||||
{orgData.litellm_budget_table.max_budget !== null
|
||||
? `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`
|
||||
: "No Limit"}
|
||||
</div>
|
||||
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
|
||||
</div>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<UserSearchModal
|
||||
isVisible={isAddMemberModalVisible}
|
||||
onCancel={() => setIsAddMemberModalVisible(false)}
|
||||
|
||||
@@ -3,14 +3,12 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Member } from "@/components/networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Space, Tooltip, Typography } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import MemberTable from "@/components/common_components/MemberTable";
|
||||
import { TeamData } from "./TeamInfo";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TeamMemberTabProps {
|
||||
teamData: TeamData;
|
||||
canEditTeam: boolean;
|
||||
@@ -84,48 +82,7 @@ export default function TeamMemberTab({
|
||||
const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || "");
|
||||
const isProxyAdmin = isProxyAdminRole(userRole || "");
|
||||
|
||||
const columns: ColumnsType<Member> = [
|
||||
{
|
||||
title: "User Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
render: (email: string | null) => (
|
||||
<Text>{email || "-"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "User ID",
|
||||
dataIndex: "user_id",
|
||||
key: "user_id",
|
||||
render: (userId: string | null) =>
|
||||
userId === "default_user_id" ? (
|
||||
<Tag color="blue">Default Proxy Admin</Tag>
|
||||
) : (
|
||||
<Text>{userId}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Space direction="horizontal">
|
||||
Team Role
|
||||
<Tooltip title="This role applies only to this team and is independent from the user's proxy-level role.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
dataIndex: "role",
|
||||
key: "role",
|
||||
render: (role: string) => (
|
||||
<Space>
|
||||
{role?.toLowerCase() === "admin" ? (
|
||||
<CrownOutlined />
|
||||
) : (
|
||||
<UserOutlined />
|
||||
)}
|
||||
<Text style={{ textTransform: "capitalize" }}>{role}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
const extraColumns: ColumnsType<Member> = [
|
||||
{
|
||||
title: (
|
||||
<Space direction="horizontal">
|
||||
@@ -137,9 +94,7 @@ export default function TeamMemberTab({
|
||||
),
|
||||
key: "spend",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<Text>
|
||||
${formatNumberWithCommas(getUserSpend(record.user_id), 4)}
|
||||
</Text>
|
||||
<Typography.Text>${formatNumberWithCommas(getUserSpend(record.user_id), 4)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -148,9 +103,9 @@ export default function TeamMemberTab({
|
||||
render: (_: unknown, record: Member) => {
|
||||
const budget = getUserBudget(record.user_id);
|
||||
return (
|
||||
<Text >
|
||||
<Typography.Text>
|
||||
{budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"}
|
||||
</Text>
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -165,69 +120,36 @@ export default function TeamMemberTab({
|
||||
),
|
||||
key: "rate_limits",
|
||||
render: (_: unknown, record: Member) => (
|
||||
<Text>{getUserRateLimits(record.user_id)}</Text>
|
||||
<Typography.Text>{getUserRateLimits(record.user_id)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
fixed: "right",
|
||||
width: 120,
|
||||
render: (_: unknown, record: Member) =>
|
||||
canEditTeam ? (
|
||||
<div className="flex gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit member"
|
||||
dataTestId="edit-member"
|
||||
onClick={() => {
|
||||
const membership = teamData.team_memberships.find(
|
||||
(tm) => tm.user_id === record.user_id
|
||||
);
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team:
|
||||
membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit:
|
||||
membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit:
|
||||
membership?.litellm_budget_table?.rpm_limit || null,
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
{(isProxyAdmin ||
|
||||
(isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)) && (
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete member"
|
||||
dataTestId="delete-member"
|
||||
onClick={() => handleMemberDelete(record)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={teamData.team_info.members_with_roles}
|
||||
rowKey={(record, index) => record.user_id || String(index)}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: "max-content" }}
|
||||
/>
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
type="primary"
|
||||
onClick={() => setIsAddMemberModalVisible(true)}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
<MemberTable
|
||||
members={teamData.team_info.members_with_roles}
|
||||
canEdit={canEditTeam}
|
||||
onEdit={(record) => {
|
||||
const membership = teamData.team_memberships.find(
|
||||
(tm) => tm.user_id === record.user_id
|
||||
);
|
||||
const enhancedMember = {
|
||||
...record,
|
||||
max_budget_in_team: membership?.litellm_budget_table?.max_budget || null,
|
||||
tpm_limit: membership?.litellm_budget_table?.tpm_limit || null,
|
||||
rpm_limit: membership?.litellm_budget_table?.rpm_limit || null,
|
||||
};
|
||||
setSelectedEditMember(enhancedMember);
|
||||
setIsEditMemberModalVisible(true);
|
||||
}}
|
||||
onDelete={handleMemberDelete}
|
||||
onAddMember={() => setIsAddMemberModalVisible(true)}
|
||||
roleColumnTitle="Team Role"
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
showDeleteForMember={() =>
|
||||
isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user