From 01ace78dcc6b9c077e0d5a38d2a52811a6bc294d Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sat, 15 Feb 2025 15:48:06 -0800 Subject: [PATCH] Add remaining org CRUD endpoints + support deleting orgs on UI (#8561) * feat(organization_endpoints.py): expose new `/organization/delete` endpoint. Cascade org deletion to member, teams and keys Ensures any org deletion is handled correctly * test(test_organizations.py): add simple test to ensure org deletion works * feat(organization_endpoints.py): expose /organization/update endpoint, and define response models for org delete + update * fix(organizations.tsx): support org delete on UI + move org/delete endpoint to use DELETE * feat(organization_endpoints.py): support `/organization/member_update` endpoint Allow admin to update member's role within org * feat(organization_endpoints.py): support deleting member from org * test(test_organizations.py): add e2e test to ensure org member flow works * fix(organization_endpoints.py): fix code qa check * fix(schema.prisma): don't introduce ondelete:cascade - breaking change * docs(organization_endpoints.py): document missing params * refactor(organization_view.tsx): initial commit creating a generic update member component shared between org and team member classes * feat(organization_view.tsx): support updating org member role on UI * feat(organization_view.tsx): allow proxy admin to delete members from org --- .../proxy/_experimental/out/onboarding.html | 1 - litellm/proxy/_types.py | 46 ++- .../organization_endpoints.py | 308 +++++++++++++++++- tests/test_organizations.py | 224 +++++++++++++ .../src/components/networking.tsx | 110 +++++++ .../organization/organization_view.tsx | 68 +++- .../src/components/organizations.tsx | 14 +- .../src/components/team/edit_membership.tsx | 242 ++++++++++---- .../src/components/team/team_info.tsx | 13 +- 9 files changed, 932 insertions(+), 94 deletions(-) delete mode 100644 litellm/proxy/_experimental/out/onboarding.html 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" } + ] + }} />