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
This commit is contained in:
Krish Dholakia 2025-02-15 15:48:06 -08:00 committed by GitHub
parent 1bac18a98c
commit 01ace78dcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 932 additions and 94 deletions

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,14 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
import httpx 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 typing_extensions import Required, TypedDict
from litellm.types.integrations.slack_alerting import AlertType from litellm.types.integrations.slack_alerting import AlertType
@ -393,6 +400,7 @@ class LiteLLMRoutes(enum.Enum):
"/organization/info", "/organization/info",
"/organization/delete", "/organization/delete",
"/organization/member_add", "/organization/member_add",
"/organization/member_update",
] ]
# All routes accesible by an Org Admin # All routes accesible by an Org Admin
@ -1110,6 +1118,10 @@ class OrganizationRequest(LiteLLMPydanticObjectBase):
organizations: List[str] organizations: List[str]
class DeleteOrganizationRequest(LiteLLMPydanticObjectBase):
organization_ids: List[str] # required
class KeyManagementSystem(enum.Enum): class KeyManagementSystem(enum.Enum):
GOOGLE_KMS = "google_kms" GOOGLE_KMS = "google_kms"
AZURE_KEY_VAULT = "azure_key_vault" AZURE_KEY_VAULT = "azure_key_vault"
@ -1494,6 +1506,18 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase):
updated_by: str 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): class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable):
"""Returned by the /organization/info endpoint and /organization/list endpoint""" """Returned by the /organization/info endpoint and /organization/list endpoint"""
@ -2097,8 +2121,26 @@ class OrganizationMemberDeleteRequest(MemberDeleteRequest):
organization_id: str organization_id: str
ROLES_WITHIN_ORG = [
LitellmUserRoles.ORG_ADMIN,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
]
class OrganizationMemberUpdateRequest(OrganizationMemberDeleteRequest): 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): class OrganizationMemberUpdateResponse(MemberUpdateResponse):

View file

@ -19,6 +19,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from litellm._logging import verbose_proxy_logger from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import * from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 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 ( from litellm.proxy.management_helpers.utils import (
get_new_internal_user_defaults, get_new_internal_user_defaults,
management_endpoint_wrapper, management_endpoint_wrapper,
@ -179,24 +183,105 @@ async def new_organization(
return response return response
@router.post( @router.patch(
"/organization/update", "/organization/update",
tags=["organization management"], tags=["organization management"],
dependencies=[Depends(user_api_key_auth)], dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_OrganizationTableWithMembers,
) )
async def update_organization(): async def update_organization(
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues""" data: LiteLLM_OrganizationTableUpdate,
raise NotImplementedError("Not Implemented Yet") 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", "/organization/delete",
tags=["organization management"], tags=["organization management"],
dependencies=[Depends(user_api_key_auth)], dependencies=[Depends(user_api_key_auth)],
response_model=List[LiteLLM_OrganizationTableWithMembers],
) )
async def delete_organization(): async def delete_organization(
"""[TODO] Not Implemented yet. Let us know if you need this - https://github.com/BerriAI/litellm/issues""" data: DeleteOrganizationRequest,
raise NotImplementedError("Not Implemented Yet") 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( @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( async def add_member_to_organization(
member: OrgMember, member: OrgMember,
organization_id: str, organization_id: str,

View file

@ -7,6 +7,49 @@ import time, uuid
from openai import AsyncOpenAI 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): async def new_organization(session, i, organization_alias, max_budget=None):
url = "http://0.0.0.0:4000/organization/new" url = "http://0.0.0.0:4000/organization/new"
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"} 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() 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): async def list_organization(session, i):
url = "http://0.0.0.0:4000/organization/list" url = "http://0.0.0.0:4000/organization/list"
headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"} headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
@ -84,3 +220,91 @@ async def test_organization_list():
if len(response_json) == 0: if len(response_json) == 0:
raise Exception("Return empty list of organization") 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))

View file

@ -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) => { export const getTotalSpendCall = async (accessToken: String) => {
/** /**
* Get all models on proxy * 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 ( export const userUpdateUserCall = async (
accessToken: string, accessToken: string,
formValues: any, // Assuming formValues is an object formValues: any, // Assuming formValues is an object

View file

@ -23,9 +23,9 @@ import { Button, Form, Input, Select, message, InputNumber, Tooltip } from "antd
import { InfoCircleOutlined } from '@ant-design/icons'; import { InfoCircleOutlined } from '@ant-design/icons';
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; 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 UserSearchModal from "../common_components/user_search_modal";
import MemberModal from "../team/edit_membership";
interface OrganizationInfoProps { interface OrganizationInfoProps {
organizationId: string; organizationId: string;
@ -51,6 +51,8 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const [form] = Form.useForm(); const [form] = Form.useForm();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
const canEditOrg = is_org_admin || is_proxy_admin; const canEditOrg = is_org_admin || is_proxy_admin;
@ -95,6 +97,42 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
} }
}; };
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) => { const handleOrgUpdate = async (values: any) => {
try { try {
if (!accessToken) return; if (!accessToken) return;
@ -241,14 +279,19 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
icon={PencilAltIcon} icon={PencilAltIcon}
size="sm" size="sm"
onClick={() => { onClick={() => {
// TODO: Implement edit member functionality setSelectedEditMember({
"role": member.user_role,
"user_email": member.user_email,
"user_id": member.user_id
});
setIsEditMemberModalVisible(true);
}} }}
/> />
<Icon <Icon
icon={TrashIcon} icon={TrashIcon}
size="sm" size="sm"
onClick={() => { onClick={() => {
// TODO: Implement delete member functionality handleMemberDelete(member);
}} }}
/> />
</> </>
@ -403,6 +446,23 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
]} ]}
defaultRole="internal_user" defaultRole="internal_user"
/> />
<MemberModal
visible={isEditMemberModalVisible}
onCancel={() => 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" }
]
}}
/>
</div> </div>
); );
}; };

View file

@ -24,8 +24,9 @@ import { InfoCircleOutlined } from '@ant-design/icons';
import { PencilAltIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline"; import { PencilAltIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
import { TextInput } from "@tremor/react"; import { TextInput } from "@tremor/react";
import { getModelDisplayName } from './key_team_helpers/fetch_available_models_team_key'; import { getModelDisplayName } from './key_team_helpers/fetch_available_models_team_key';
import { message } from 'antd';
import OrganizationInfoView from './organization/organization_view'; import OrganizationInfoView from './organization/organization_view';
import { Organization, organizationListCall, organizationCreateCall } from './networking'; import { Organization, organizationListCall, organizationCreateCall, organizationDeleteCall } from './networking';
interface OrganizationsTableProps { interface OrganizationsTableProps {
organizations: Organization[]; organizations: Organization[];
userRole: string; userRole: string;
@ -80,18 +81,13 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
if (!orgToDelete || !accessToken) return; if (!orgToDelete || !accessToken) return;
try { try {
const response = await fetch(`/organization/delete?organization_id=${orgToDelete}`, { await organizationDeleteCall(accessToken, orgToDelete);
method: 'DELETE', message.success('Organization deleted successfully');
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) throw new Error('Failed to delete organization');
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setOrgToDelete(null); setOrgToDelete(null);
// Refresh organizations list // Refresh organizations list
fetchOrganizations(accessToken, setOrganizations);
} catch (error) { } catch (error) {
console.error('Error deleting organization:', error); console.error('Error deleting organization:', error);
} }

View file

@ -2,58 +2,130 @@ import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Select as AntSelect, Button as AntButton, message } from 'antd'; import { Modal, Form, Input, Select as AntSelect, Button as AntButton, message } from 'antd';
import { Select, SelectItem } from "@tremor/react"; import { Select, SelectItem } from "@tremor/react";
import { Card, Text } from "@tremor/react"; import { Card, Text } from "@tremor/react";
import { Member } from "@/components/networking";
interface TeamMemberModalProps { interface BaseMember {
visible: boolean; user_email?: string;
onCancel: () => void; user_id?: string;
onSubmit: (data: Member) => void; role: string;
initialData?: Member | null;
mode: 'add' | 'edit';
} }
const TeamMemberModal: React.FC<TeamMemberModalProps> = ({ 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<T extends BaseMember> {
visible: boolean;
onCancel: () => void;
onSubmit: (data: T) => void;
initialData?: T | null;
mode: 'add' | 'edit';
config: ModalConfig;
}
const MemberModal = <T extends BaseMember>({
visible, visible,
onCancel, onCancel,
onSubmit, onSubmit,
initialData, initialData,
mode mode,
}) => { config
}: MemberModalProps<T>) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
console.log("Initial Data:", initialData);
// Reset form and set initial values when modal becomes visible or initialData changes
useEffect(() => { useEffect(() => {
if (initialData) { if (visible) {
if (mode === 'edit' && initialData) {
// For edit mode, use the initialData values
form.setFieldsValue({ form.setFieldsValue({
user_email: initialData.user_email, ...initialData,
user_id: initialData.user_id, // Ensure role is set correctly for editing
role: initialData.role, 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) => { const handleSubmit = async (values: any) => {
try { try {
const formData: Member = { // Trim string values
user_email: values.user_email, const formData = Object.entries(values).reduce((acc, [key, value]) => ({
user_id: values.user_id, ...acc,
role: values.role, [key]: typeof value === 'string' ? value.trim() : value
}; }), {}) as T;
onSubmit(formData); onSubmit(formData);
form.resetFields(); form.resetFields();
message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} team member`); message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} member`);
} catch (error) { } catch (error) {
message.error('Failed to submit form'); message.error('Failed to submit form');
console.error('Form submission error:', error); 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 (
<Input
className="px-3 py-2 border rounded-md w-full"
onChange={(e) => {
e.target.value = e.target.value.trim();
}}
/>
);
case 'select':
return (
<AntSelect>
{field.options?.map(option => (
<AntSelect.Option key={option.value} value={option.value}>
{option.label}
</AntSelect.Option>
))}
</AntSelect>
);
default:
return null;
}
};
return ( return (
<Modal <Modal
title={mode === 'add' ? "Add Team Member" : "Edit Team Member"} title={config.title || (mode === 'add' ? "Add Member" : "Edit Member")}
visible={visible} open={visible}
width={800} width={800}
footer={null} footer={null}
onCancel={onCancel} onCancel={onCancel}
@ -64,12 +136,8 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
labelCol={{ span: 8 }} labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }} wrapperCol={{ span: 16 }}
labelAlign="left" labelAlign="left"
initialValues={{
user_email: initialData?.user_email?.trim() || '',
user_id: initialData?.user_id?.trim() || '',
role: initialData?.role || 'user',
}}
> >
{config.showEmail && (
<Form.Item <Form.Item
label="Email" label="Email"
name="user_email" name="user_email"
@ -79,7 +147,6 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
]} ]}
> >
<Input <Input
name="user_email"
className="px-3 py-2 border rounded-md w-full" className="px-3 py-2 border rounded-md w-full"
placeholder="user@example.com" placeholder="user@example.com"
onChange={(e) => { onChange={(e) => {
@ -87,18 +154,21 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
}} }}
/> />
</Form.Item> </Form.Item>
)}
{config.showEmail && config.showUserId && (
<div className="text-center mb-4"> <div className="text-center mb-4">
<Text>OR</Text> <Text>OR</Text>
</div> </div>
)}
{config.showUserId && (
<Form.Item <Form.Item
label="User ID" label="User ID"
name="user_id" name="user_id"
className="mb-4" className="mb-4"
> >
<Input <Input
name="user_id"
className="px-3 py-2 border rounded-md w-full" className="px-3 py-2 border rounded-md w-full"
placeholder="user_123" placeholder="user_123"
onChange={(e) => { onChange={(e) => {
@ -106,25 +176,61 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
}} }}
/> />
</Form.Item> </Form.Item>
)}
<Form.Item <Form.Item
label="Member Role" label={
<div className="flex items-center gap-2">
<span>Role</span>
{mode === 'edit' && initialData && (
<span className="text-gray-500 text-sm">
(Current: {getRoleLabel(initialData.role)})
</span>
)}
</div>
}
name="role" name="role"
className="mb-4" className="mb-4"
rules={[ rules={[
{ required: true, message: 'Please select a role!' } { required: true, message: 'Please select a role!' }
]} ]}
> >
<AntSelect defaultValue="user"> <AntSelect>
<AntSelect.Option value="admin">admin</AntSelect.Option> {mode === 'edit' && initialData
<AntSelect.Option value="user">user</AntSelect.Option> ? [
// 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 => (
<AntSelect.Option key={option.value} value={option.value}>
{option.label}
</AntSelect.Option>
))
: config.roleOptions.map(option => (
<AntSelect.Option key={option.value} value={option.value}>
{option.label}
</AntSelect.Option>
))}
</AntSelect> </AntSelect>
</Form.Item> </Form.Item>
<div style={{ textAlign: "right", marginTop: "20px" }}> {config.additionalFields?.map(field => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
className="mb-4"
rules={field.rules}
>
{renderField(field)}
</Form.Item>
))}
<div className="text-right mt-6">
<AntButton <AntButton
onClick={onCancel} onClick={onCancel}
style={{ marginRight: 8 }} className="mr-2"
> >
Cancel Cancel
</AntButton> </AntButton>
@ -140,4 +246,4 @@ const TeamMemberModal: React.FC<TeamMemberModalProps> = ({
); );
}; };
export default TeamMemberModal; export default MemberModal;

View file

@ -26,7 +26,7 @@ import {
Select as Select2, Select as Select2,
} from "antd"; } from "antd";
import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline"; 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 UserSearchModal from "@/components/common_components/user_search_modal";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import ModelAliasesCard from "./model_aliases_card"; import ModelAliasesCard from "./model_aliases_card";
@ -486,12 +486,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</TabPanels> </TabPanels>
</TabGroup> </TabGroup>
<TeamMemberModal <MemberModal
visible={isEditMemberModalVisible} visible={isEditMemberModalVisible}
onCancel={() => setIsEditMemberModalVisible(false)} onCancel={() => setIsEditMemberModalVisible(false)}
onSubmit={handleMemberUpdate} onSubmit={handleMemberUpdate}
initialData={selectedEditMember} initialData={selectedEditMember}
mode="edit" mode="edit"
config={{
title: "Edit Member",
showEmail: true,
showUserId: true,
roleOptions: [
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" }
]
}}
/> />
<UserSearchModal <UserSearchModal