mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 02:34:29 +00:00
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:
parent
1bac18a98c
commit
01ace78dcc
9 changed files with 932 additions and 94 deletions
File diff suppressed because one or more lines are too long
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
form.setFieldsValue({
|
if (mode === 'edit' && initialData) {
|
||||||
user_email: initialData.user_email,
|
// For edit mode, use the initialData values
|
||||||
user_id: initialData.user_id,
|
form.setFieldsValue({
|
||||||
role: initialData.role,
|
...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) => {
|
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,67 +136,101 @@ 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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Form.Item
|
{config.showEmail && (
|
||||||
label="Email"
|
<Form.Item
|
||||||
name="user_email"
|
label="Email"
|
||||||
className="mb-4"
|
|
||||||
rules={[
|
|
||||||
{ type: 'email', message: 'Please enter a valid email!' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="user_email"
|
name="user_email"
|
||||||
className="px-3 py-2 border rounded-md w-full"
|
className="mb-4"
|
||||||
placeholder="user@example.com"
|
rules={[
|
||||||
onChange={(e) => {
|
{ type: 'email', message: 'Please enter a valid email!' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="px-3 py-2 border rounded-md w-full"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
onChange={(e) => {
|
||||||
e.target.value = e.target.value.trim();
|
e.target.value = e.target.value.trim();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-center mb-4">
|
{config.showEmail && config.showUserId && (
|
||||||
<Text>OR</Text>
|
<div className="text-center mb-4">
|
||||||
</div>
|
<Text>OR</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
{config.showUserId && (
|
||||||
label="User ID"
|
<Form.Item
|
||||||
name="user_id"
|
label="User ID"
|
||||||
className="mb-4"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="user_id"
|
name="user_id"
|
||||||
className="px-3 py-2 border rounded-md w-full"
|
className="mb-4"
|
||||||
placeholder="user_123"
|
>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
|
className="px-3 py-2 border rounded-md w-full"
|
||||||
|
placeholder="user_123"
|
||||||
|
onChange={(e) => {
|
||||||
e.target.value = e.target.value.trim();
|
e.target.value = e.target.value.trim();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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;
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue