diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 128e99ae84a..3718ab2cbc0 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -1,22 +1,31 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; -import { useQueryClient } from "@tanstack/react-query"; -import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; -import { createTeamAliasMap } from "@/utils/teamUtils"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; import { - Badge, - Card, - Grid, - Text, - TextInput, - Title, - Button as TremorButton, -} from "@tremor/react"; -import { Button, Form, Input, Select, Tabs, Typography } from "antd"; -import type { ColumnsType } from "antd/es/table"; -import { CheckIcon, CopyIcon } from "lucide-react"; + organizationKeys, + useOrganization, +} from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useQueryClient } from "@tanstack/react-query"; +import { + formatNumberWithCommas, + copyToClipboard as utilCopyToClipboard, +} from "@/utils/dataUtils"; +import { createTeamAliasMap } from "@/utils/teamUtils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { ArrowLeft, CheckIcon, CopyIcon } from "lucide-react"; import React, { useMemo, useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; import MemberTable from "../common_components/MemberTable"; import UserSearchModal from "../common_components/user_search_modal"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; @@ -24,7 +33,6 @@ import { ModelSelect } from "../ModelSelect/ModelSelect"; import NotificationsManager from "../molecules/notifications_manager"; import { Member, - Organization, organizationMemberAddCall, organizationMemberDeleteCall, organizationMemberUpdateCall, @@ -45,45 +53,100 @@ interface OrganizationInfoProps { editOrg: boolean; } +type OrgEditValues = { + organization_alias: string; + models: string[]; + tpm_limit: number | null; + rpm_limit: number | null; + max_budget: number | null; + budget_duration: string | null; + metadata: string; + vector_stores: string[]; + mcp_servers_and_groups: { servers: string[]; accessGroups: string[] }; +}; + +// Member-table extra columns rendered as plain JSX (MemberTable receives them +// as antd ColumnsType under the hood, but the cell renderer is just JSX so +// shadcn-friendly). +type MemberExtraColumn = { + title: string; + key: string; + render: (_: unknown, record: Member) => React.ReactNode; +}; + const OrganizationInfoView: React.FC = ({ organizationId, onClose, accessToken, is_org_admin, is_proxy_admin, - userModels, + userModels: _userModels, editOrg, }) => { const queryClient = useQueryClient(); const { data: orgData, isLoading: loading } = useOrganization(organizationId); - const [form] = Form.useForm(); const [isEditing, setIsEditing] = useState(false); const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); - const [selectedEditMember, setSelectedEditMember] = useState(null); + const [selectedEditMember, setSelectedEditMember] = useState( + null, + ); const [copiedStates, setCopiedStates] = useState>({}); const [isOrgSaving, setIsOrgSaving] = useState(false); const canEditOrg = is_org_admin || is_proxy_admin; const { data: teams } = useTeams(); - const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); + const editForm = useForm({ + defaultValues: { + organization_alias: "", + models: [], + tpm_limit: null, + rpm_limit: null, + max_budget: null, + budget_duration: null, + metadata: "", + vector_stores: [], + mcp_servers_and_groups: { servers: [], accessGroups: [] }, + }, + mode: "onSubmit", + }); + + // Reset form when entering edit mode (or when orgData arrives). + React.useEffect(() => { + if (orgData && isEditing) { + editForm.reset({ + 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 ?? null, + 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 || [], + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgData, isEditing]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleMemberAdd = async (values: any) => { try { - if (accessToken == null) { - return; - } - + if (accessToken == null) return; const member: Member = { user_email: values.user_email, user_id: values.user_id, role: values.role, }; - const response = await organizationMemberAddCall(accessToken, organizationId, member); - + await organizationMemberAddCall(accessToken, organizationId, member); NotificationsManager.success("Organization member added successfully"); setIsAddMemberModalVisible(false); - form.resetFields(); queryClient.invalidateQueries({ queryKey: organizationKeys.all }); } catch (error) { NotificationsManager.fromBackend("Failed to add organization member"); @@ -91,20 +154,18 @@ const OrganizationInfoView: React.FC = ({ } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleMemberUpdate = async (values: any) => { try { if (!accessToken) return; - const member: Member = { user_email: values.user_email, user_id: values.user_id, role: values.role, }; - - const response = await organizationMemberUpdateCall(accessToken, organizationId, member); + await organizationMemberUpdateCall(accessToken, organizationId, member); NotificationsManager.success("Organization member updated successfully"); setIsEditMemberModalVisible(false); - form.resetFields(); queryClient.invalidateQueries({ queryKey: organizationKeys.all }); } catch (error) { NotificationsManager.fromBackend("Failed to update organization member"); @@ -112,14 +173,17 @@ const OrganizationInfoView: React.FC = ({ } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleMemberDelete = async (values: any) => { try { if (!accessToken) return; - - await organizationMemberDeleteCall(accessToken, organizationId, values.user_id); + await organizationMemberDeleteCall( + accessToken, + organizationId, + values.user_id, + ); NotificationsManager.success("Organization member deleted successfully"); setIsEditMemberModalVisible(false); - form.resetFields(); queryClient.invalidateQueries({ queryKey: organizationKeys.all }); } catch (error) { NotificationsManager.fromBackend("Failed to delete organization member"); @@ -127,11 +191,11 @@ const OrganizationInfoView: React.FC = ({ } }; - const handleOrgUpdate = async (values: any) => { + const handleOrgUpdate = editForm.handleSubmit(async (values) => { try { - if (!accessToken) return; + if (!accessToken || !orgData) return; setIsOrgSaving(true); - + // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateData: any = { organization_id: organizationId, organization_alias: values.organization_alias, @@ -144,30 +208,24 @@ const OrganizationInfoView: React.FC = ({ }, metadata: values.metadata ? JSON.parse(values.metadata) : null, }; - - // Handle object_permission updates - if (values.vector_stores !== undefined || values.mcp_servers_and_groups !== undefined) { + if ( + values.vector_stores !== undefined || + values.mcp_servers_and_groups !== undefined + ) { updateData.object_permission = { ...orgData?.object_permission, vector_stores: values.vector_stores || [], }; - - if (values.mcp_servers_and_groups !== undefined) { - const { servers, accessGroups } = values.mcp_servers_and_groups || { - servers: [], - accessGroups: [], - }; - if (servers && servers.length > 0) { - updateData.object_permission.mcp_servers = servers; - } - if (accessGroups && accessGroups.length > 0) { - updateData.object_permission.mcp_access_groups = accessGroups; - } - } + const { servers, accessGroups } = values.mcp_servers_and_groups || { + servers: [], + accessGroups: [], + }; + if (servers && servers.length > 0) + updateData.object_permission.mcp_servers = servers; + if (accessGroups && accessGroups.length > 0) + updateData.object_permission.mcp_access_groups = accessGroups; } - - const response = await organizationUpdateCall(accessToken, updateData); - + await organizationUpdateCall(accessToken, updateData); NotificationsManager.success("Organization settings updated successfully"); setIsEditing(false); queryClient.invalidateQueries({ queryKey: organizationKeys.all }); @@ -177,27 +235,26 @@ const OrganizationInfoView: React.FC = ({ } finally { setIsOrgSaving(false); } - }; + }); - if (loading) { - return
Loading...
; - } + if (loading) return
Loading...
; + if (!orgData) return
Organization not found
; - if (!orgData) { - return
Organization not found
; - } - - const copyToClipboard = async (text: string | null | undefined, key: string) => { + const copyToClipboard = async ( + text: string | null | undefined, + key: string, + ) => { const success = await utilCopyToClipboard(text); if (success) { setCopiedStates((prev) => ({ ...prev, [key]: true })); - setTimeout(() => { - setCopiedStates((prev) => ({ ...prev, [key]: false })); - }, 2000); + setTimeout( + () => setCopiedStates((prev) => ({ ...prev, [key]: false })), + 2000, + ); } }; - const orgExtraColumns: ColumnsType = [ + const orgExtraColumns: MemberExtraColumn[] = [ { title: "Spend (USD)", key: "spend", @@ -207,9 +264,7 @@ const OrganizationInfoView: React.FC = ({ ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined; return ( - - ${formatNumberWithCommas(orgMember?.spend ?? 0, 4)} - + ${formatNumberWithCommas(orgMember?.spend ?? 0, 4)} ); }, }, @@ -222,306 +277,398 @@ const OrganizationInfoView: React.FC = ({ ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined; return ( - + {orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"} - + ); }, }, ]; return ( -
+
- + +

{orgData.organization_alias}

- {orgData.organization_id} + + {orgData.organization_id} +
- - Organization Details -
- Created: {new Date(orgData.created_at).toLocaleDateString()} - Updated: {new Date(orgData.updated_at).toLocaleDateString()} - Created By: {orgData.created_by} -
-
+ > + + Overview + Members + Settings + - - Budget Status -
- ${formatNumberWithCommas(orgData.spend, 4)} - - of{" "} - {orgData.litellm_budget_table.max_budget === null - ? "Unlimited" - : `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`} - - {orgData.litellm_budget_table.budget_duration && ( - Reset: {orgData.litellm_budget_table.budget_duration} + +
+ +

Organization Details

+
+

Created: {new Date(orgData.created_at).toLocaleDateString()}

+

Updated: {new Date(orgData.updated_at).toLocaleDateString()}

+

Created By: {orgData.created_by}

+
+
+ +

Budget Status

+
+

+ ${formatNumberWithCommas(orgData.spend, 4)} +

+

+ of{" "} + {orgData.litellm_budget_table.max_budget === null + ? "Unlimited" + : `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`} +

+ {orgData.litellm_budget_table.budget_duration && ( +

+ Reset: {orgData.litellm_budget_table.budget_duration} +

+ )} +
+
+ +

Rate Limits

+
+

TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}

+

RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}

+ {orgData.litellm_budget_table.max_parallel_requests && ( +

+ Max Parallel Requests:{" "} + {orgData.litellm_budget_table.max_parallel_requests} +

+ )} +
+
+ +

Models

+
+ {orgData.models.length === 0 ? ( + All proxy models + ) : ( + orgData.models.map((model, index) => ( + + {model} + + )) + )} +
+
+ +

Teams

+
+ {orgData.teams?.map((team, index) => ( + + {teamAliasMap[team.team_id] || team.team_id} + + ))} +
+
+ +
+
+ + +
+ ({ + 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} + emptyText="No members found" + /> +
+
+ + + +
+

Organization Settings

+ {canEditOrg && !isEditing && ( + + )} +
+ + {isEditing ? ( + +
+
+ + + {editForm.formState.errors.organization_alias && ( +

+ {editForm.formState.errors.organization_alias.message as string} +

)}
- - - Rate Limits -
- TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"} - RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"} - {orgData.litellm_budget_table.max_parallel_requests && ( - Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests} - )} +
+ + ( + field.onChange(values)} + context="organization" + options={{ + includeSpecialOptions: true, + showAllProxyModelsOverride: true, + }} + /> + )} + />
- - - Models -
- {orgData.models.length === 0 ? ( - All proxy models - ) : ( - orgData.models.map((model, index) => ( - - {model} - - )) - )} +
+ + ( + + field.onChange(v ?? null) + } + /> + )} + />
- - - Teams -
- {orgData.teams?.map((team, index) => ( - - {teamAliasMap[team.team_id] || team.team_id} + +
+ + ( + + )} + /> +
+ +
+ + ( + + field.onChange(v ?? null) + } + /> + )} + /> +
+ +
+ + ( + + field.onChange(v ?? null) + } + /> + )} + /> +
+ +
+ + ( + field.onChange(values)} + value={field.value} + accessToken={accessToken || ""} + placeholder="Select vector stores" + /> + )} + /> +
+ +
+ + ( + field.onChange(values)} + value={field.value} + accessToken={accessToken || ""} + placeholder="Select MCP servers and access groups" + /> + )} + /> +
+ +
+ +