diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html
deleted file mode 100644
index 5f99ad5db9..0000000000
--- a/litellm/proxy/_experimental/out/onboarding.html
+++ /dev/null
@@ -1 +0,0 @@
-
LiteLLM Dashboard
\ No newline at end of file
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 4c296a5798..4da023681e 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -5,7 +5,14 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
import httpx
-from pydantic import BaseModel, ConfigDict, Field, Json, model_validator
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ Json,
+ field_validator,
+ model_validator,
+)
from typing_extensions import Required, TypedDict
from litellm.types.integrations.slack_alerting import AlertType
@@ -393,6 +400,7 @@ class LiteLLMRoutes(enum.Enum):
"/organization/info",
"/organization/delete",
"/organization/member_add",
+ "/organization/member_update",
]
# All routes accesible by an Org Admin
@@ -1110,6 +1118,10 @@ class OrganizationRequest(LiteLLMPydanticObjectBase):
organizations: List[str]
+class DeleteOrganizationRequest(LiteLLMPydanticObjectBase):
+ organization_ids: List[str] # required
+
+
class KeyManagementSystem(enum.Enum):
GOOGLE_KMS = "google_kms"
AZURE_KEY_VAULT = "azure_key_vault"
@@ -1494,6 +1506,18 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase):
updated_by: str
+class LiteLLM_OrganizationTableUpdate(LiteLLMPydanticObjectBase):
+ """Represents user-controllable params for a LiteLLM_OrganizationTable record"""
+
+ organization_id: Optional[str] = None
+ organization_alias: Optional[str] = None
+ budget_id: Optional[str] = None
+ spend: Optional[float] = None
+ metadata: Optional[dict] = None
+ models: Optional[List[str]] = None
+ updated_by: Optional[str] = None
+
+
class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable):
"""Returned by the /organization/info endpoint and /organization/list endpoint"""
@@ -2097,8 +2121,26 @@ class OrganizationMemberDeleteRequest(MemberDeleteRequest):
organization_id: str
+ROLES_WITHIN_ORG = [
+ LitellmUserRoles.ORG_ADMIN,
+ LitellmUserRoles.INTERNAL_USER,
+ LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
+]
+
+
class OrganizationMemberUpdateRequest(OrganizationMemberDeleteRequest):
- max_budget_in_organization: float
+ max_budget_in_organization: Optional[float] = None
+ role: Optional[LitellmUserRoles] = None
+
+ @field_validator("role")
+ def validate_role(
+ cls, value: Optional[LitellmUserRoles]
+ ) -> Optional[LitellmUserRoles]:
+ if value is not None and value not in ROLES_WITHIN_ORG:
+ raise ValueError(
+ f"Invalid role. Must be one of: {[role.value for role in ROLES_WITHIN_ORG]}"
+ )
+ return value
class OrganizationMemberUpdateResponse(MemberUpdateResponse):
diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py
index 20ba084d30..3b6c625abe 100644
--- a/litellm/proxy/management_endpoints/organization_endpoints.py
+++ b/litellm/proxy/management_endpoints/organization_endpoints.py
@@ -19,6 +19,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.management_endpoints.budget_management_endpoints import (
+ new_budget,
+ update_budget,
+)
from litellm.proxy.management_helpers.utils import (
get_new_internal_user_defaults,
management_endpoint_wrapper,
@@ -179,24 +183,105 @@ async def new_organization(
return response
-@router.post(
+@router.patch(
"/organization/update",
tags=["organization management"],
dependencies=[Depends(user_api_key_auth)],
+ response_model=LiteLLM_OrganizationTableWithMembers,
)
-async def update_organization():
- """[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
- raise NotImplementedError("Not Implemented Yet")
+async def update_organization(
+ data: LiteLLM_OrganizationTableUpdate,
+ user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+):
+ """
+ Update an organization
+ """
+ from litellm.proxy.proxy_server import prisma_client
+
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ )
+
+ if user_api_key_dict.user_id is None:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": "Cannot associate a user_id to this action. Check `/key/info` to validate if 'user_id' is set."
+ },
+ )
+
+ if data.updated_by is None:
+ data.updated_by = user_api_key_dict.user_id
+
+ response = await prisma_client.db.litellm_organizationtable.update(
+ where={"organization_id": data.organization_id},
+ data=data.model_dump(exclude_none=True),
+ include={"members": True, "teams": True, "litellm_budget_table": True},
+ )
+
+ return response
-@router.post(
+@router.delete(
"/organization/delete",
tags=["organization management"],
dependencies=[Depends(user_api_key_auth)],
+ response_model=List[LiteLLM_OrganizationTableWithMembers],
)
-async def delete_organization():
- """[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues"""
- raise NotImplementedError("Not Implemented Yet")
+async def delete_organization(
+ data: DeleteOrganizationRequest,
+ user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+):
+ """
+ Delete an organization
+
+ # Parameters:
+
+ - organization_ids: List[str] - The organization ids to delete.
+ """
+ from litellm.proxy.proxy_server import prisma_client
+
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ )
+
+ if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
+ raise HTTPException(
+ status_code=401,
+ detail={"error": "Only proxy admins can delete organizations"},
+ )
+
+ deleted_orgs = []
+ for organization_id in data.organization_ids:
+ # delete all teams in the organization
+ await prisma_client.db.litellm_teamtable.delete_many(
+ where={"organization_id": organization_id}
+ )
+ # delete all members in the organization
+ await prisma_client.db.litellm_organizationmembership.delete_many(
+ where={"organization_id": organization_id}
+ )
+ # delete all keys in the organization
+ await prisma_client.db.litellm_verificationtoken.delete_many(
+ where={"organization_id": organization_id}
+ )
+ # delete the organization
+ deleted_org = await prisma_client.db.litellm_organizationtable.delete(
+ where={"organization_id": organization_id},
+ include={"members": True, "teams": True, "litellm_budget_table": True},
+ )
+ if deleted_org is None:
+ raise HTTPException(
+ status_code=404,
+ detail={"error": f"Organization={organization_id} not found"},
+ )
+ deleted_orgs.append(deleted_org)
+
+ return deleted_orgs
@router.get(
@@ -429,6 +514,213 @@ async def organization_member_add(
)
+async def find_member_if_email(
+ user_email: str, prisma_client: PrismaClient
+) -> LiteLLM_UserTable:
+ """
+ Find a member if the user_email is in LiteLLM_UserTable
+ """
+
+ try:
+ existing_user_email_row: BaseModel = (
+ await prisma_client.db.litellm_usertable.find_unique(
+ where={"user_email": user_email}
+ )
+ )
+ except Exception:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": f"Unique user not found for user_email={user_email}. Potential duplicate OR non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead."
+ },
+ )
+ existing_user_email_row_pydantic = LiteLLM_UserTable(
+ **existing_user_email_row.model_dump()
+ )
+ return existing_user_email_row_pydantic
+
+
+@router.patch(
+ "/organization/member_update",
+ tags=["organization management"],
+ dependencies=[Depends(user_api_key_auth)],
+ response_model=LiteLLM_OrganizationMembershipTable,
+)
+@management_endpoint_wrapper
+async def organization_member_update(
+ data: OrganizationMemberUpdateRequest,
+ user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+):
+ """
+ Update a member's role in an organization
+ """
+ try:
+ from litellm.proxy.proxy_server import prisma_client
+
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ )
+
+ # Check if organization exists
+ existing_organization_row = (
+ await prisma_client.db.litellm_organizationtable.find_unique(
+ where={"organization_id": data.organization_id}
+ )
+ )
+ if existing_organization_row is None:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": f"Organization not found for organization_id={getattr(data, 'organization_id', None)}"
+ },
+ )
+
+ # Check if member exists in organization
+ if data.user_email is not None and data.user_id is None:
+ existing_user_email_row = await find_member_if_email(
+ data.user_email, prisma_client
+ )
+ data.user_id = existing_user_email_row.user_id
+
+ try:
+ existing_organization_membership = (
+ await prisma_client.db.litellm_organizationmembership.find_unique(
+ where={
+ "user_id_organization_id": {
+ "user_id": data.user_id,
+ "organization_id": data.organization_id,
+ }
+ }
+ )
+ )
+ except Exception as e:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": f"Error finding organization membership for user_id={data.user_id} in organization={data.organization_id}: {e}"
+ },
+ )
+ if existing_organization_membership is None:
+ raise HTTPException(
+ status_code=404,
+ detail={
+ "error": f"Member not found in organization for user_id={data.user_id}"
+ },
+ )
+
+ # Update member role
+ if data.role is not None:
+ await prisma_client.db.litellm_organizationmembership.update(
+ where={
+ "user_id_organization_id": {
+ "user_id": data.user_id,
+ "organization_id": data.organization_id,
+ }
+ },
+ data={"user_role": data.role},
+ )
+ if data.max_budget_in_organization is not None:
+ # if budget_id is None, create a new budget
+ budget_id = existing_organization_membership.budget_id or str(uuid.uuid4())
+ if existing_organization_membership.budget_id is None:
+ new_budget_obj = BudgetNewRequest(
+ budget_id=budget_id, max_budget=data.max_budget_in_organization
+ )
+ await new_budget(
+ budget_obj=new_budget_obj, user_api_key_dict=user_api_key_dict
+ )
+ else:
+ # update budget table with new max_budget
+ await update_budget(
+ budget_obj=BudgetNewRequest(
+ budget_id=budget_id, max_budget=data.max_budget_in_organization
+ ),
+ user_api_key_dict=user_api_key_dict,
+ )
+
+ # update organization membership with new budget_id
+ await prisma_client.db.litellm_organizationmembership.update(
+ where={
+ "user_id_organization_id": {
+ "user_id": data.user_id,
+ "organization_id": data.organization_id,
+ }
+ },
+ data={"budget_id": budget_id},
+ )
+ final_organization_membership: Optional[BaseModel] = (
+ await prisma_client.db.litellm_organizationmembership.find_unique(
+ where={
+ "user_id_organization_id": {
+ "user_id": data.user_id,
+ "organization_id": data.organization_id,
+ }
+ },
+ include={"litellm_budget_table": True},
+ )
+ )
+
+ if final_organization_membership is None:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": f"Member not found in organization={data.organization_id} for user_id={data.user_id}"
+ },
+ )
+
+ final_organization_membership_pydantic = LiteLLM_OrganizationMembershipTable(
+ **final_organization_membership.model_dump(exclude_none=True)
+ )
+ return final_organization_membership_pydantic
+ except Exception as e:
+ verbose_proxy_logger.exception(f"Error updating member in organization: {e}")
+ raise e
+
+
+@router.delete(
+ "/organization/member_delete",
+ tags=["organization management"],
+ dependencies=[Depends(user_api_key_auth)],
+)
+async def organization_member_delete(
+ data: OrganizationMemberDeleteRequest,
+ user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+):
+ """
+ Delete a member from an organization
+ """
+ try:
+ from litellm.proxy.proxy_server import prisma_client
+
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ )
+
+ if data.user_email is not None and data.user_id is None:
+ existing_user_email_row = await find_member_if_email(
+ data.user_email, prisma_client
+ )
+ data.user_id = existing_user_email_row.user_id
+
+ member_to_delete = await prisma_client.db.litellm_organizationmembership.delete(
+ where={
+ "user_id_organization_id": {
+ "user_id": data.user_id,
+ "organization_id": data.organization_id,
+ }
+ }
+ )
+ return member_to_delete
+
+ except Exception as e:
+ verbose_proxy_logger.exception(f"Error deleting member from organization: {e}")
+ raise e
+
+
async def add_member_to_organization(
member: OrgMember,
organization_id: str,
diff --git a/tests/test_organizations.py b/tests/test_organizations.py
index 588d838f29..565aba14d4 100644
--- a/tests/test_organizations.py
+++ b/tests/test_organizations.py
@@ -7,6 +7,49 @@ import time, uuid
from openai import AsyncOpenAI
+async def new_user(
+ session,
+ i,
+ user_id=None,
+ budget=None,
+ budget_duration=None,
+ models=["azure-models"],
+ team_id=None,
+ user_email=None,
+):
+ url = "http://0.0.0.0:4000/user/new"
+ headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
+ data = {
+ "models": models,
+ "aliases": {"mistral-7b": "gpt-3.5-turbo"},
+ "duration": None,
+ "max_budget": budget,
+ "budget_duration": budget_duration,
+ "user_email": user_email,
+ }
+
+ if user_id is not None:
+ data["user_id"] = user_id
+
+ if team_id is not None:
+ data["team_id"] = team_id
+
+ async with session.post(url, headers=headers, json=data) as response:
+ status = response.status
+ response_text = await response.text()
+
+ print(f"Response {i} (Status code: {status}):")
+ print(response_text)
+ print()
+
+ if status != 200:
+ raise Exception(
+ f"Request {i} did not return a 200 status code: {status}, response: {response_text}"
+ )
+
+ return await response.json()
+
+
async def new_organization(session, i, organization_alias, max_budget=None):
url = "http://0.0.0.0:4000/organization/new"
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
@@ -30,6 +73,99 @@ async def new_organization(session, i, organization_alias, max_budget=None):
return await response.json()
+async def add_member_to_org(
+ session, i, organization_id, user_id, user_role="internal_user"
+):
+ url = "http://0.0.0.0:4000/organization/member_add"
+ headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
+ data = {
+ "organization_id": organization_id,
+ "member": {
+ "user_id": user_id,
+ "role": user_role,
+ },
+ }
+
+ async with session.post(url, headers=headers, json=data) as response:
+ status = response.status
+ response_text = await response.text()
+
+ print(f"Response {i} (Status code: {status}):")
+ print(response_text)
+ print()
+
+ if status != 200:
+ raise Exception(f"Request {i} did not return a 200 status code: {status}")
+
+ return await response.json()
+
+
+async def update_member_role(
+ session, i, organization_id, user_id, user_role="internal_user"
+):
+ url = "http://0.0.0.0:4000/organization/member_update"
+ headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
+ data = {
+ "organization_id": organization_id,
+ "user_id": user_id,
+ "role": user_role,
+ }
+
+ async with session.patch(url, headers=headers, json=data) as response:
+ status = response.status
+ response_text = await response.text()
+
+ print(f"Response {i} (Status code: {status}):")
+ print(response_text)
+ print()
+
+ if status != 200:
+ raise Exception(f"Request {i} did not return a 200 status code: {status}")
+
+ return await response.json()
+
+
+async def delete_member_from_org(session, i, organization_id, user_id):
+ url = "http://0.0.0.0:4000/organization/member_delete"
+ headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
+ data = {
+ "organization_id": organization_id,
+ "user_id": user_id,
+ }
+
+ async with session.delete(url, headers=headers, json=data) as response:
+ status = response.status
+ response_text = await response.text()
+
+ print(f"Response {i} (Status code: {status}):")
+ print(response_text)
+ print()
+
+ if status != 200:
+ raise Exception(f"Request {i} did not return a 200 status code: {status}")
+
+ return await response.json()
+
+
+async def delete_organization(session, i, organization_id):
+ url = "http://0.0.0.0:4000/organization/delete"
+ headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
+ data = {"organization_ids": [organization_id]}
+
+ async with session.delete(url, headers=headers, json=data) as response:
+ status = response.status
+ response_text = await response.text()
+
+ print(f"Response {i} (Status code: {status}):")
+ print(response_text)
+ print()
+
+ if status != 200:
+ raise Exception(f"Request {i} did not return a 200 status code: {status}")
+
+ return await response.json()
+
+
async def list_organization(session, i):
url = "http://0.0.0.0:4000/organization/list"
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
@@ -84,3 +220,91 @@ async def test_organization_list():
if len(response_json) == 0:
raise Exception("Return empty list of organization")
+
+
+@pytest.mark.asyncio
+async def test_organization_delete():
+ """
+ create a new organization
+ delete the organization
+ check if the Organization list is set
+ """
+ organization_alias = f"Organization: {uuid.uuid4()}"
+ async with aiohttp.ClientSession() as session:
+ tasks = [
+ new_organization(
+ session=session, i=0, organization_alias=organization_alias
+ )
+ ]
+ await asyncio.gather(*tasks)
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
+
+ organization_id = response_json[0]["organization_id"]
+ await delete_organization(session, i=0, organization_id=organization_id)
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
+
+
+@pytest.mark.asyncio
+async def test_organization_member_flow():
+ """
+ create a new organization
+ add a new member to the organization
+ check if the member is added to the organization
+ update the member's role in the organization
+ delete the member from the organization
+ check if the member is deleted from the organization
+ """
+ organization_alias = f"Organization: {uuid.uuid4()}"
+ async with aiohttp.ClientSession() as session:
+ response_json = await new_organization(
+ session=session, i=0, organization_alias=organization_alias
+ )
+ organization_id = response_json["organization_id"]
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
+
+ new_user_response_json = await new_user(
+ session=session, i=0, user_email=f"test_user_{uuid.uuid4()}@example.com"
+ )
+ user_id = new_user_response_json["user_id"]
+
+ await add_member_to_org(
+ session, i=0, organization_id=organization_id, user_id=user_id
+ )
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
+
+ for orgs in response_json:
+ tmp_organization_id = orgs["organization_id"]
+ if (
+ tmp_organization_id is not None
+ and tmp_organization_id == organization_id
+ ):
+ user_id = orgs["members"][0]["user_id"]
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
+
+ await update_member_role(
+ session,
+ i=0,
+ organization_id=organization_id,
+ user_id=user_id,
+ user_role="org_admin",
+ )
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
+
+ await delete_member_from_org(
+ session, i=0, organization_id=organization_id, user_id=user_id
+ )
+
+ response_json = await list_organization(session, i=0)
+ print(len(response_json))
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 97f6e17943..e00bb56c42 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -905,6 +905,37 @@ export const organizationCreateCall = async (
}
};
+export const organizationDeleteCall = async (
+ accessToken: string,
+ organizationID: string
+) => {
+ try {
+ const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/delete` : `/organization/delete`;
+ const response = await fetch(url, {
+ method: "DELETE",
+ headers: {
+ [globalLitellmHeaderName]: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ organization_ids: [organizationID]
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ handleError(errorData);
+ throw new Error(`Error deleting organization: ${errorData}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error("Failed to delete organization:", error);
+ throw error;
+ }
+};
+
export const getTotalSpendCall = async (accessToken: String) => {
/**
* Get all models on proxy
@@ -2642,6 +2673,85 @@ export const organizationMemberAddCall = async (
}
};
+export const organizationMemberDeleteCall = async (
+ accessToken: string,
+ organizationId: string,
+ userId: string
+) => {
+ try {
+ console.log("Form Values in organizationMemberDeleteCall:", userId); // Log the form values before making the API call
+
+ const url = proxyBaseUrl
+ ? `${proxyBaseUrl}/organization/member_delete`
+ : `/organization/member_delete`;
+
+ const response = await fetch(url, {
+ method: "DELETE",
+ headers: {
+ [globalLitellmHeaderName]: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ organization_id: organizationId,
+ user_id: userId
+ }),
+ });
+
+ 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;
+ } catch (error) {
+ console.error("Failed to delete organization member:", error);
+ throw error;
+ }
+};
+export const organizationMemberUpdateCall = async (
+ accessToken: string,
+ organizationId: string,
+ formValues: Member // Assuming formValues is an object
+) => {
+ try {
+ console.log("Form Values in organizationMemberUpdateCall:", formValues); // Log the form values before making the API call
+
+ const url = proxyBaseUrl
+ ? `${proxyBaseUrl}/organization/member_update`
+ : `/organization/member_update`;
+
+ const response = await fetch(url, {
+ method: "PATCH",
+ headers: {
+ [globalLitellmHeaderName]: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ organization_id: organizationId,
+ ...formValues, // Include formValues in the request body
+ }),
+ });
+
+ 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;
+ } catch (error) {
+ console.error("Failed to update organization member:", error);
+ throw error;
+ }
+};
+
export const userUpdateUserCall = async (
accessToken: string,
formValues: any, // Assuming formValues is an object
diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx
index 7c2994bb1f..593e832ce1 100644
--- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx
+++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx
@@ -23,9 +23,9 @@ import { Button, Form, Input, Select, message, InputNumber, Tooltip } from "antd
import { InfoCircleOutlined } from '@ant-design/icons';
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
-import { Member, Organization, organizationInfoCall, organizationMemberAddCall } from "../networking";
+import { Member, Organization, organizationInfoCall, organizationMemberAddCall, organizationMemberUpdateCall, organizationMemberDeleteCall } from "../networking";
import UserSearchModal from "../common_components/user_search_modal";
-
+import MemberModal from "../team/edit_membership";
interface OrganizationInfoProps {
organizationId: string;
@@ -51,6 +51,8 @@ const OrganizationInfoView: React.FC = ({
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 canEditOrg = is_org_admin || is_proxy_admin;
@@ -95,6 +97,42 @@ const OrganizationInfoView: React.FC = ({
}
};
+ 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);
+ message.success("Organization member updated successfully");
+ setIsEditMemberModalVisible(false);
+ form.resetFields();
+ fetchOrgInfo();
+ } catch (error) {
+ message.error("Failed to update organization member");
+ console.error("Error updating organization member:", error);
+ }
+ };
+
+ const handleMemberDelete = async (values: any) => {
+ try {
+ if (!accessToken) return;
+
+ await organizationMemberDeleteCall(accessToken, organizationId, values.user_id);
+ message.success("Organization member deleted successfully");
+ setIsEditMemberModalVisible(false);
+ form.resetFields();
+ fetchOrgInfo();
+ } catch (error) {
+ message.error("Failed to delete organization member");
+ console.error("Error deleting organization member:", error);
+ }
+ };
+
const handleOrgUpdate = async (values: any) => {
try {
if (!accessToken) return;
@@ -241,14 +279,19 @@ const OrganizationInfoView: React.FC = ({
icon={PencilAltIcon}
size="sm"
onClick={() => {
- // TODO: Implement edit member functionality
+ setSelectedEditMember({
+ "role": member.user_role,
+ "user_email": member.user_email,
+ "user_id": member.user_id
+ });
+ setIsEditMemberModalVisible(true);
}}
/>
{
- // TODO: Implement delete member functionality
+ handleMemberDelete(member);
}}
/>
>
@@ -403,6 +446,23 @@ const OrganizationInfoView: React.FC = ({
]}
defaultRole="internal_user"
/>
+ setIsEditMemberModalVisible(false)}
+ onSubmit={handleMemberUpdate}
+ initialData={selectedEditMember}
+ mode="edit"
+ config={{
+ title: "Edit Member",
+ showEmail: true,
+ showUserId: true,
+ roleOptions: [
+ { label: "Org Admin", value: "org_admin" },
+ { label: "Internal User", value: "internal_user" },
+ { label: "Internal User Viewer", value: "internal_user_viewer" }
+ ]
+ }}
+ />
);
};
diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/components/organizations.tsx
index 58df99dba3..5e6cf6f58e 100644
--- a/ui/litellm-dashboard/src/components/organizations.tsx
+++ b/ui/litellm-dashboard/src/components/organizations.tsx
@@ -24,8 +24,9 @@ import { InfoCircleOutlined } from '@ant-design/icons';
import { PencilAltIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
import { TextInput } from "@tremor/react";
import { getModelDisplayName } from './key_team_helpers/fetch_available_models_team_key';
+import { message } from 'antd';
import OrganizationInfoView from './organization/organization_view';
-import { Organization, organizationListCall, organizationCreateCall } from './networking';
+import { Organization, organizationListCall, organizationCreateCall, organizationDeleteCall } from './networking';
interface OrganizationsTableProps {
organizations: Organization[];
userRole: string;
@@ -80,18 +81,13 @@ const OrganizationsTable: React.FC = ({
if (!orgToDelete || !accessToken) return;
try {
- const response = await fetch(`/organization/delete?organization_id=${orgToDelete}`, {
- method: 'DELETE',
- headers: {
- 'Authorization': `Bearer ${accessToken}`
- }
- });
+ await organizationDeleteCall(accessToken, orgToDelete);
+ message.success('Organization deleted successfully');
- if (!response.ok) throw new Error('Failed to delete organization');
-
setIsDeleteModalOpen(false);
setOrgToDelete(null);
// Refresh organizations list
+ fetchOrganizations(accessToken, setOrganizations);
} catch (error) {
console.error('Error deleting organization:', error);
}
diff --git a/ui/litellm-dashboard/src/components/team/edit_membership.tsx b/ui/litellm-dashboard/src/components/team/edit_membership.tsx
index 3d1696916e..27e64193e3 100644
--- a/ui/litellm-dashboard/src/components/team/edit_membership.tsx
+++ b/ui/litellm-dashboard/src/components/team/edit_membership.tsx
@@ -2,58 +2,130 @@ 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";
-import { Member } from "@/components/networking";
-interface TeamMemberModalProps {
- visible: boolean;
- onCancel: () => void;
- onSubmit: (data: Member) => void;
- initialData?: Member | null;
- mode: 'add' | 'edit';
+
+interface BaseMember {
+ user_email?: string;
+ user_id?: string;
+ role: string;
}
-const TeamMemberModal: React.FC = ({
+interface ModalConfig {
+ title: string;
+ roleOptions: Array<{
+ label: string;
+ value: string;
+ }>;
+ defaultRole?: string;
+ showEmail?: boolean;
+ showUserId?: boolean;
+ additionalFields?: Array<{
+ name: string;
+ label: string;
+ type: 'input' | 'select';
+ options?: Array<{ label: string; value: string }>;
+ rules?: any[];
+ }>;
+}
+
+interface MemberModalProps {
+ visible: boolean;
+ onCancel: () => void;
+ onSubmit: (data: T) => void;
+ initialData?: T | null;
+ mode: 'add' | 'edit';
+ config: ModalConfig;
+}
+
+const MemberModal = ({
visible,
onCancel,
onSubmit,
initialData,
- mode
-}) => {
+ mode,
+ config
+}: MemberModalProps) => {
const [form] = Form.useForm();
+ console.log("Initial Data:", initialData);
+
+ // Reset form and set initial values when modal becomes visible or initialData changes
useEffect(() => {
- if (initialData) {
- form.setFieldsValue({
- user_email: initialData.user_email,
- user_id: initialData.user_id,
- role: initialData.role,
- });
+ if (visible) {
+ if (mode === 'edit' && initialData) {
+ // For edit mode, use the initialData values
+ form.setFieldsValue({
+ ...initialData,
+ // Ensure role is set correctly for editing
+ role: initialData.role || config.defaultRole
+ });
+ } else {
+ // For add mode, reset to defaults
+ form.resetFields();
+ form.setFieldsValue({
+ role: config.defaultRole || config.roleOptions[0]?.value
+ });
+ }
}
- }, [initialData, form]);
-
+ }, [visible, initialData, mode, form, config.defaultRole, config.roleOptions]);
const handleSubmit = async (values: any) => {
try {
- const formData: Member = {
- user_email: values.user_email,
- user_id: values.user_id,
- role: values.role,
- };
+ // Trim string values
+ const formData = Object.entries(values).reduce((acc, [key, value]) => ({
+ ...acc,
+ [key]: typeof value === 'string' ? value.trim() : value
+ }), {}) as T;
onSubmit(formData);
form.resetFields();
- message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} team member`);
+ message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} member`);
} catch (error) {
message.error('Failed to submit form');
console.error('Form submission error:', error);
}
};
+ // Helper function to get role label from value
+ const getRoleLabel = (value: string) => {
+ return config.roleOptions.find(option => option.value === value)?.label || value;
+ };
+ const renderField = (field: {
+ name: string;
+ label: string;
+ type: 'input' | 'select';
+ options?: Array<{ label: string; value: string }>;
+ rules?: any[];
+ }) => {
+ switch (field.type) {
+ case 'input':
+ return (
+ {
+ e.target.value = e.target.value.trim();
+ }}
+ />
+ );
+ case 'select':
+ return (
+
+ {field.options?.map(option => (
+
+ {option.label}
+
+ ))}
+
+ );
+ default:
+ return null;
+ }
+ };
return (
= ({
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
- initialValues={{
- user_email: initialData?.user_email?.trim() || '',
- user_id: initialData?.user_id?.trim() || '',
- role: initialData?.role || 'user',
- }}
>
-
- {
+ className="mb-4"
+ rules={[
+ { type: 'email', message: 'Please enter a valid email!' }
+ ]}
+ >
+ {
e.target.value = e.target.value.trim();
- }}
- />
-
+ }}
+ />
+
+ )}
-
- OR
-
+ {config.showEmail && config.showUserId && (
+
+ OR
+
+ )}
-
- {
+ className="mb-4"
+ >
+ {
e.target.value = e.target.value.trim();
- }}
- />
-
+ }}
+ />
+
+ )}
+ Role
+ {mode === 'edit' && initialData && (
+
+ (Current: {getRoleLabel(initialData.role)})
+
+ )}
+
+ }
name="role"
className="mb-4"
rules={[
{ required: true, message: 'Please select a role!' }
]}
>
-
- admin
- user
+
+ {mode === 'edit' && initialData
+ ? [
+ // Current role first
+ ...config.roleOptions.filter(option => option.value === initialData.role),
+ // Then all other roles
+ ...config.roleOptions.filter(option => option.value !== initialData.role)
+ ].map(option => (
+
+ {option.label}
+
+ ))
+ : config.roleOptions.map(option => (
+
+ {option.label}
+
+ ))}
-
+ {config.additionalFields?.map(field => (
+
+ {renderField(field)}
+
+ ))}
+
+
Cancel
@@ -140,4 +246,4 @@ const TeamMemberModal: React.FC
= ({
);
};
-export default TeamMemberModal;
\ No newline at end of file
+export default MemberModal;
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx
index 65b0f6a561..3b6fc09e29 100644
--- a/ui/litellm-dashboard/src/components/team/team_info.tsx
+++ b/ui/litellm-dashboard/src/components/team/team_info.tsx
@@ -26,7 +26,7 @@ import {
Select as Select2,
} from "antd";
import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
-import TeamMemberModal from "./edit_membership";
+import MemberModal from "./edit_membership";
import UserSearchModal from "@/components/common_components/user_search_modal";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import ModelAliasesCard from "./model_aliases_card";
@@ -486,12 +486,21 @@ const TeamInfoView: React.FC = ({
- setIsEditMemberModalVisible(false)}
onSubmit={handleMemberUpdate}
initialData={selectedEditMember}
mode="edit"
+ config={{
+ title: "Edit Member",
+ showEmail: true,
+ showUserId: true,
+ roleOptions: [
+ { label: "Admin", value: "admin" },
+ { label: "User", value: "user" }
+ ]
+ }}
/>