diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e7dde2dc5a..1d381ab145 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -15,7 +15,7 @@ import asyncio import traceback import uuid from datetime import datetime, timedelta, timezone -from typing import List, Optional +from typing import Any, List, Optional, Union import fastapi from fastapi import APIRouter, Depends, Header, HTTPException, Request, status @@ -71,6 +71,35 @@ def _update_internal_new_user_params(data_json: dict, data: NewUserRequest) -> d return data_json +async def _check_duplicate_user_email( + user_email: Optional[str], prisma_client: Any +) -> None: + """ + Helper function to check if a user email already exists in the database. + + Args: + user_email (Optional[str]): Email to check + prisma_client (Any): Database client instance + + Raises: + Exception: If database is not connected + HTTPException: If user with email already exists + """ + if user_email: + if prisma_client is None: + raise Exception("Database not connected") + + existing_user = await prisma_client.db.litellm_usertable.find_first( + where={"user_email": user_email} + ) + + if existing_user is not None: + raise HTTPException( + status_code=400, + detail={"error": f"User with email {user_email} already exists"}, + ) + + @router.post( "/user/new", tags=["Internal User management"], @@ -137,101 +166,116 @@ async def new_user( }' ``` """ - from litellm.proxy.proxy_server import general_settings, proxy_logging_obj + try: + from litellm.proxy.proxy_server import ( + general_settings, + prisma_client, + proxy_logging_obj, + ) - data_json = data.json() # type: ignore - data_json = _update_internal_new_user_params(data_json, data) - response = await generate_key_helper_fn(request_type="user", **data_json) - # Admin UI Logic - # Add User to Team and Organization - # if team_id passed add this user to the team - if data_json.get("team_id", None) is not None: - from litellm.proxy.management_endpoints.team_endpoints import team_member_add + # Check for duplicate email + await _check_duplicate_user_email(data.user_email, prisma_client) - try: - await team_member_add( - data=TeamMemberAddRequest( - team_id=data_json.get("team_id", None), - member=Member( - user_id=data_json.get("user_id", None), - role="user", - user_email=data_json.get("user_email", None), + data_json = data.json() # type: ignore + data_json = _update_internal_new_user_params(data_json, data) + response = await generate_key_helper_fn(request_type="user", **data_json) + # Admin UI Logic + # Add User to Team and Organization + # if team_id passed add this user to the team + if data_json.get("team_id", None) is not None: + from litellm.proxy.management_endpoints.team_endpoints import ( + team_member_add, + ) + + try: + await team_member_add( + data=TeamMemberAddRequest( + team_id=data_json.get("team_id", None), + member=Member( + user_id=data_json.get("user_id", None), + role="user", + user_email=data_json.get("user_email", None), + ), ), - ), - http_request=Request( - scope={"type": "http", "path": "/user/new"}, - ), - user_api_key_dict=user_api_key_dict, - ) - except HTTPException as e: - if e.status_code == 400 and ( - "already exists" in str(e) or "doesn't exist" in str(e) - ): - verbose_proxy_logger.debug( - "litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): User already exists in team - {}".format( - str(e) - ) + http_request=Request( + scope={"type": "http", "path": "/user/new"}, + ), + user_api_key_dict=user_api_key_dict, ) - else: - verbose_proxy_logger.debug( - "litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): Exception occured - {}".format( - str(e) + except HTTPException as e: + if e.status_code == 400 and ( + "already exists" in str(e) or "doesn't exist" in str(e) + ): + verbose_proxy_logger.debug( + "litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): User already exists in team - {}".format( + str(e) + ) ) - ) - except Exception as e: - if "already exists" in str(e) or "doesn't exist" in str(e): - verbose_proxy_logger.debug( - "litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): User already exists in team - {}".format( - str(e) + else: + verbose_proxy_logger.debug( + "litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): Exception occured - {}".format( + str(e) + ) ) - ) - else: - raise e + except Exception as e: + if "already exists" in str(e) or "doesn't exist" in str(e): + verbose_proxy_logger.debug( + "litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): User already exists in team - {}".format( + str(e) + ) + ) + else: + raise e - if data.send_invite_email is True: - # check if user has setup email alerting - if "email" not in general_settings.get("alerting", []): - raise ValueError( - "Email alerting not setup on config.yaml. Please set `alerting=['email']. \nDocs: https://docs.litellm.ai/docs/proxy/email`" + if data.send_invite_email is True: + # check if user has setup email alerting + if "email" not in general_settings.get("alerting", []): + raise ValueError( + "Email alerting not setup on config.yaml. Please set `alerting=['email']. \nDocs: https://docs.litellm.ai/docs/proxy/email`" + ) + + event = WebhookEvent( + event="internal_user_created", + event_group="internal_user", + event_message="Welcome to LiteLLM Proxy", + token=response.get("token", ""), + spend=response.get("spend", 0.0), + max_budget=response.get("max_budget", 0.0), + user_id=response.get("user_id", None), + user_email=response.get("user_email", None), + team_id=response.get("team_id", "Default Team"), + key_alias=response.get("key_alias", None), ) - event = WebhookEvent( - event="internal_user_created", - event_group="internal_user", - event_message="Welcome to LiteLLM Proxy", - token=response.get("token", ""), - spend=response.get("spend", 0.0), - max_budget=response.get("max_budget", 0.0), - user_id=response.get("user_id", None), + # If user configured email alerting - send an Email letting their end-user know the key was created + asyncio.create_task( + proxy_logging_obj.slack_alerting_instance.send_key_created_or_user_invited_email( + webhook_event=event, + ) + ) + + return NewUserResponse( + key=response.get("token", ""), + expires=response.get("expires", None), + max_budget=response["max_budget"], + user_id=response["user_id"], + user_role=response.get("user_role", None), user_email=response.get("user_email", None), - team_id=response.get("team_id", "Default Team"), - key_alias=response.get("key_alias", None), + user_alias=response.get("user_alias", None), + teams=response.get("teams", None), + team_id=response.get("team_id", None), + metadata=response.get("metadata", None), + models=response.get("models", None), + tpm_limit=response.get("tpm_limit", None), + rpm_limit=response.get("rpm_limit", None), + budget_duration=response.get("budget_duration", None), + model_max_budget=response.get("model_max_budget", None), ) - - # If user configured email alerting - send an Email letting their end-user know the key was created - asyncio.create_task( - proxy_logging_obj.slack_alerting_instance.send_key_created_or_user_invited_email( - webhook_event=event, - ) + except Exception as e: + verbose_proxy_logger.exception( + "/user/new: Exception occured - {}".format(str(e)) ) - - return NewUserResponse( - key=response.get("token", ""), - expires=response.get("expires", None), - max_budget=response["max_budget"], - user_id=response["user_id"], - user_role=response.get("user_role", None), - user_email=response.get("user_email", None), - user_alias=response.get("user_alias", None), - teams=response.get("teams", None), - team_id=response.get("team_id", None), - metadata=response.get("metadata", None), - models=response.get("models", None), - tpm_limit=response.get("tpm_limit", None), - rpm_limit=response.get("rpm_limit", None), - budget_duration=response.get("budget_duration", None), - model_max_budget=response.get("model_max_budget", None), - ) + raise handle_exception_on_proxy(e) @router.get( diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index b944efe4cd..307e95217f 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.64.1", "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.13.3", + "@types/papaparse": "^5.3.15", "antd": "^5.13.2", "fs": "^0.0.1-security", "jsonwebtoken": "^9.0.2", @@ -22,6 +23,7 @@ "moment": "^2.30.1", "next": "^14.2.15", "openai": "^4.28.0", + "papaparse": "^5.5.2", "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", @@ -981,6 +983,14 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/papaparse": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", + "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -5334,6 +5344,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 9bf0b7ec49..79f096106d 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.64.1", "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.13.3", + "@types/papaparse": "^5.3.15", "antd": "^5.13.2", "fs": "^0.0.1-security", "jsonwebtoken": "^9.0.2", @@ -23,6 +24,7 @@ "moment": "^2.30.1", "next": "^14.2.15", "openai": "^4.28.0", + "papaparse": "^5.5.2", "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx new file mode 100644 index 0000000000..d9438d3e12 --- /dev/null +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -0,0 +1,645 @@ +import React, { useState, useEffect } from "react"; +import { Button as TremorButton, Text } from "@tremor/react"; +import { Modal, Table, Upload, message } from "antd"; +import { UploadOutlined, DownloadOutlined } from "@ant-design/icons"; +import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking"; +import Papa from "papaparse"; +import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/outline"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { InvitationLink } from "./onboarding_link"; + +interface BulkCreateUsersProps { + accessToken: string; + teams: any[] | null; + possibleUIRoles: null | Record>; + onUsersCreated?: () => void; +} + +interface UserData { + user_email: string; + user_role: string; + teams?: string | string[]; + metadata?: string; + max_budget?: string | number; + budget_duration?: string; + models?: string | string[]; + status?: string; + error?: string; + rowNumber?: number; + isValid?: boolean; + key?: string; + invitation_link?: string; +} + +// Define an interface for the UI settings +interface UISettings { + PROXY_BASE_URL: string | null; + PROXY_LOGOUT_URL: string | null; + DEFAULT_TEAM_DISABLED: boolean; + SSO_ENABLED: boolean; +} + +const BulkCreateUsersButton: React.FC = ({ + accessToken, + teams, + possibleUIRoles, + onUsersCreated, +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [parsedData, setParsedData] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [parseError, setParseError] = useState(null); + const [uiSettings, setUISettings] = useState(null); + const [baseUrl, setBaseUrl] = useState("http://localhost:4000"); + + useEffect(() => { + // Get UI settings + const fetchUISettings = async () => { + try { + const uiSettingsResponse = await getProxyUISettings(accessToken); + setUISettings(uiSettingsResponse); + } catch (error) { + console.error("Error fetching UI settings:", error); + } + }; + + fetchUISettings(); + + // Set base URL + const base = new URL("/", window.location.href); + setBaseUrl(base.toString()); + }, [accessToken]); + + const downloadTemplate = () => { + const template = [ + ["user_email", "user_role", "teams", "max_budget", "budget_duration", "models"], + ["user@example.com", "internal_user", "team-id-1,team-id-2", "100", "30d", "gpt-3.5-turbo,gpt-4"], + ]; + + const csv = Papa.unparse(template); + const blob = new Blob([csv], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "bulk_users_template.csv"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + + const handleFileUpload = (file: File) => { + setParseError(null); + Papa.parse(file, { + complete: (results) => { + const headers = results.data[0] as string[]; + const requiredColumns = ['user_email', 'user_role']; + + // Check if all required columns are present + const missingColumns = requiredColumns.filter(col => !headers.includes(col)); + if (missingColumns.length > 0) { + setParseError(`Your CSV is missing these required columns: ${missingColumns.join(', ')}`); + setParsedData([]); + return; + } + + try { + const userData = results.data.slice(1).map((row: any, index: number) => { + const user: UserData = { + user_email: row[headers.indexOf("user_email")]?.trim() || '', + user_role: row[headers.indexOf("user_role")]?.trim() || '', + teams: row[headers.indexOf("teams")]?.trim(), + max_budget: row[headers.indexOf("max_budget")]?.trim(), + budget_duration: row[headers.indexOf("budget_duration")]?.trim(), + models: row[headers.indexOf("models")]?.trim(), + rowNumber: index + 2, + isValid: true, + error: '', + }; + + // Validate the row + const errors: string[] = []; + if (!user.user_email) errors.push('Email is required'); + if (!user.user_role) errors.push('Role is required'); + if (user.user_email && !user.user_email.includes('@')) errors.push('Invalid email format'); + + // Validate user role + const validRoles = ['proxy_admin', 'proxy_admin_view_only', 'internal_user', 'internal_user_view_only']; + if (user.user_role && !validRoles.includes(user.user_role)) { + errors.push(`Invalid role. Must be one of: ${validRoles.join(', ')}`); + } + + // Validate max_budget if provided + if (user.max_budget && isNaN(parseFloat(user.max_budget.toString()))) { + errors.push('Max budget must be a number'); + } + + if (errors.length > 0) { + user.isValid = false; + user.error = errors.join(', '); + } + + return user; + }); + + const validData = userData.filter(user => user.isValid); + setParsedData(userData); + + if (validData.length === 0) { + setParseError('No valid users found in the CSV. Please check the errors below.'); + } else if (validData.length < userData.length) { + setParseError(`Found ${userData.length - validData.length} row(s) with errors. Please correct them before proceeding.`); + } else { + message.success(`Successfully parsed ${validData.length} users`); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setParseError(`Error parsing CSV: ${errorMessage}`); + setParsedData([]); + } + }, + error: (error) => { + setParseError(`Failed to parse CSV file: ${error.message}`); + setParsedData([]); + }, + header: false, + }); + return false; + }; + + const handleBulkCreate = async () => { + setIsProcessing(true); + const updatedData = parsedData.map(user => ({ ...user, status: 'pending' })); + setParsedData(updatedData); + + let anySuccessful = false; + + for (let index = 0; index < updatedData.length; index++) { + const user = updatedData[index]; + try { + // Convert teams from comma-separated string to array if provided + const processedUser = { ...user }; + if (processedUser.teams && typeof processedUser.teams === 'string') { + processedUser.teams = processedUser.teams.split(',').map(team => team.trim()); + } + + // Convert models from comma-separated string to array if provided + if (processedUser.models && typeof processedUser.models === 'string') { + processedUser.models = processedUser.models.split(',').map(model => model.trim()); + } + + // Convert max_budget to number if provided + if (processedUser.max_budget && processedUser.max_budget.toString().trim() !== '') { + processedUser.max_budget = parseFloat(processedUser.max_budget.toString()); + } + + + const response = await userCreateCall(accessToken, null, processedUser); + console.log('Full response:', response); + + // Check if response has key or user_id, indicating success + if (response && (response.key || response.user_id)) { + anySuccessful = true; + console.log('Success case triggered'); + const user_id = response.data?.user_id || response.user_id; + + // Create invitation link for the user + try { + if (!uiSettings?.SSO_ENABLED) { + // Regular invitation flow + const invitationData = await invitationCreateCall(accessToken, user_id); + const invitationUrl = new URL(`/ui?invitation_id=${invitationData.id}`, baseUrl).toString(); + + setParsedData(current => + current.map((u, i) => + i === index ? { + ...u, + status: 'success', + key: response.key || response.user_id, + invitation_link: invitationUrl + } : u + ) + ); + } else { + // SSO flow - just use the base URL + const invitationUrl = new URL("/ui", baseUrl).toString(); + + setParsedData(current => + current.map((u, i) => + i === index ? { + ...u, + status: 'success', + key: response.key || response.user_id, + invitation_link: invitationUrl + } : u + ) + ); + } + } catch (inviteError) { + console.error('Error creating invitation:', inviteError); + setParsedData(current => + current.map((u, i) => + i === index ? { + ...u, + status: 'success', + key: response.key || response.user_id, + error: 'User created but failed to generate invitation link' + } : u + ) + ); + } + } else { + console.log('Error case triggered'); + const errorMessage = response?.error || 'Failed to create user'; + console.log('Error message:', errorMessage); + setParsedData(current => + current.map((u, i) => + i === index ? { ...u, status: 'failed', error: errorMessage } : u + ) + ); + } + } catch (error) { + console.error('Caught error:', error); + const errorMessage = (error as any)?.response?.data?.error || + (error as Error)?.message || + String(error); + setParsedData(current => + current.map((u, i) => + i === index ? { ...u, status: 'failed', error: errorMessage } : u + ) + ); + } + } + + setIsProcessing(false); + + // Call the callback if any users were successfully created + if (anySuccessful && onUsersCreated) { + onUsersCreated(); + } + }; + + const downloadResults = () => { + const results = parsedData.map(user => ({ + user_email: user.user_email, + user_role: user.user_role, + status: user.status, + key: user.key || '', + invitation_link: user.invitation_link || '', + error: user.error || '' + })); + + const csv = Papa.unparse(results); + const blob = new Blob([csv], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "bulk_users_results.csv"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + + const columns = [ + { + title: "Row", + dataIndex: "rowNumber", + key: "rowNumber", + width: 80, + }, + { + title: "Email", + dataIndex: "user_email", + key: "user_email", + }, + { + title: "Role", + dataIndex: "user_role", + key: "user_role", + }, + { + title: "Teams", + dataIndex: "teams", + key: "teams", + }, + { + title: "Budget", + dataIndex: "max_budget", + key: "max_budget", + }, + { + title: 'Status', + key: 'status', + render: (_: any, record: UserData) => { + if (!record.isValid) { + return ( +
+
+ + Invalid +
+ {record.error && ( + {record.error} + )} +
+ ); + } + if (!record.status || record.status === 'pending') { + return Pending; + } + if (record.status === 'success') { + return ( +
+
+ + Success +
+ {record.invitation_link && ( +
+
+ + {record.invitation_link} + + message.success("Invitation link copied!")} + > + + +
+
+ )} +
+ ); + } + return ( +
+
+ + Failed +
+ {record.error && ( + + {JSON.stringify(record.error)} + + )} +
+ ); + }, + }, + ]; + + return ( + <> + setIsModalVisible(true)}> + + Bulk Invite Users + + + setIsModalVisible(false)} + bodyStyle={{ maxHeight: '70vh', overflow: 'auto' }} + footer={null} + > +
+ {/* Step indicator */} + {parsedData.length === 0 ? ( +
+
+
1
+

Download and fill the template

+
+ +
+

Add multiple users at once by following these steps:

+
    +
  1. Download our CSV template
  2. +
  3. Add your users' information to the spreadsheet
  4. +
  5. Save the file and upload it here
  6. +
  7. After creation, download the results file containing the API keys for each user
  8. +
+ +
+

Template Column Names

+
+
+
+
+

user_email

+

User's email address (required)

+
+
+
+
+
+

user_role

+

User's role (one of: "proxy_admin", "proxy_admin_view_only", "internal_user", "internal_user_view_only")

+
+
+
+
+
+

teams

+

Comma-separated team IDs (e.g., "team-1,team-2")

+
+
+
+
+
+

max_budget

+

Maximum budget as a number (e.g., "100")

+
+
+
+
+
+

budget_duration

+

Budget reset period (e.g., "30d", "1mo")

+
+
+
+
+
+

models

+

Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4")

+
+
+
+
+ + + Download CSV Template + +
+ +
+
2
+

Upload your completed CSV

+
+ +
+ +
+ +

Drag and drop your CSV file here

+

or

+ Browse files +
+
+
+
+ ) : ( +
+
+
3
+

+ {parsedData.some(user => user.status === 'success' || user.status === 'failed') + ? "User Creation Results" + : "Review and create users"} +

+
+ + {parseError && ( +
+ {parseError} +
+ )} + +
+
+
+ {parsedData.some(user => user.status === 'success' || user.status === 'failed') ? ( +
+ Creation Summary + + {parsedData.filter(d => d.status === 'success').length} Successful + + {parsedData.some(d => d.status === 'failed') && ( + + {parsedData.filter(d => d.status === 'failed').length} Failed + + )} +
+ ) : ( +
+ User Preview + + {parsedData.filter(d => d.isValid).length} of {parsedData.length} users valid + +
+ )} +
+ + {!parsedData.some(user => user.status === 'success' || user.status === 'failed') && ( +
+ { + setParsedData([]); + setParseError(null); + }} + variant="secondary" + > + Back + + d.isValid).length === 0 || isProcessing} + > + {isProcessing ? 'Creating...' : `Create ${parsedData.filter(d => d.isValid).length} Users`} + +
+ )} +
+ + {parsedData.some(user => user.status === 'success') && ( +
+
+
+ +
+
+ User creation complete + + Next step: Download the credentials file containing API keys and invitation links. + Users will need these API keys to make LLM requests through LiteLLM. + +
+
+
+ )} + + !record.isValid ? 'bg-red-50' : ''} + /> + + {!parsedData.some(user => user.status === 'success' || user.status === 'failed') && ( +
+ { + setParsedData([]); + setParseError(null); + }} + variant="secondary" + className="mr-3" + > + Back + + d.isValid).length === 0 || isProcessing} + > + {isProcessing ? 'Creating...' : `Create ${parsedData.filter(d => d.isValid).length} Users`} + +
+ )} + + {parsedData.some(user => user.status === 'success' || user.status === 'failed') && ( +
+ { + setParsedData([]); + setParseError(null); + }} + variant="secondary" + className="mr-3" + > + Start New Bulk Import + + + Download User Credentials + +
+ )} + + + )} + + + + ); +}; + +export default BulkCreateUsersButton; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/create_user_button.tsx b/ui/litellm-dashboard/src/components/create_user_button.tsx index d6c946cc8d..310f570909 100644 --- a/ui/litellm-dashboard/src/components/create_user_button.tsx +++ b/ui/litellm-dashboard/src/components/create_user_button.tsx @@ -19,6 +19,7 @@ import { invitationCreateCall, getProxyUISettings, } from "./networking"; +import BulkCreateUsers from "./bulk_create_users_button"; const { Option } = Select; interface CreateuserProps { @@ -148,16 +149,23 @@ const Createuser: React.FC = ({ message.success("API user Created"); form.resetFields(); localStorage.removeItem("userData" + userID); - } catch (error) { + } catch (error: any) { + const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user"; + message.error(errorMessage); console.error("Error creating the user:", error); } }; return ( -
+
setIsModalVisible(true)}> + Invite User + >; + onUserCreated: () => Promise; +} + const isLocal = process.env.NODE_ENV === "development"; const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; if (isLocal != true) { @@ -86,13 +98,14 @@ const ViewUserDashboard: React.FC = ({ const [openDialogId, setOpenDialogId] = React.useState(null); const [selectedItem, setSelectedItem] = useState(null); const [editModalVisible, setEditModalVisible] = useState(false); - const [selectedUser, setSelectedUser] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [userToDelete, setUserToDelete] = useState(null); const [possibleUIRoles, setPossibleUIRoles] = useState< Record> >({}); const defaultPageSize = 25; + const [searchTerm, setSearchTerm] = useState(""); // check if window is not undefined if (typeof window !== "undefined") { @@ -160,6 +173,34 @@ const ViewUserDashboard: React.FC = ({ // Close the modal }; + const refreshUserData = async () => { + if (!accessToken || !token || !userRole || !userID) { + return; + } + + try { + const userDataResponse = await userInfoCall( + accessToken, + null, + userRole, + true, + currentPage, + defaultPageSize + ); + + // Update session storage with new data + sessionStorage.setItem( + `userList_${currentPage}`, + JSON.stringify(userDataResponse) + ); + + setUserListResponse(userDataResponse); + setUserData(userDataResponse.users || []); + } catch (error) { + console.error("Error refreshing user data:", error); + } + }; + useEffect(() => { if (!accessToken || !token || !userRole || !userID) { return; @@ -221,134 +262,48 @@ const ViewUserDashboard: React.FC = ({ return
Loading...
; } - function renderPagination() { - if (!userData) return null; - - const totalPages = userListResponse?.total_pages || 0; - - const handlePageChange = (newPage: number) => { - setUserData([]); // Clear current users - setCurrentPage(newPage); - }; - - - return ( -
-
- Showing Page {currentPage } of {totalPages} -
-
- - -
-
- ); - } + const tableColumns = columns( + possibleUIRoles, + (user) => { + setSelectedUser(user); + setEditModalVisible(true); + }, + handleDelete + ); return ( -
- - - -
- - - -
- - - User ID - User Email - Role - User Spend ($ USD) - User Max Budget ($ USD) - API Keys - - - - - {userData.map((user: any) => ( - - {user.user_id || "-"} - {user.user_email || "-"} - - {possibleUIRoles?.[user?.user_role]?.ui_label || "-"} - - - {user.spend ? user.spend?.toFixed(2) : "-"} - - - {user.max_budget !== null ? user.max_budget : "Unlimited"} - - - - {user.key_count > 0 ? ( - - {user.key_count} Keys - - ) : ( - - No Keys - - )} - {/* {user.key_aliases.filter(key => key !== null).length} Keys */} - - - - { - setSelectedUser(user); - setEditModalVisible(true); - }} - > - View Keys - - handleDelete(user.user_id)} - > - Delete - - - - ))} - -
- - -
-
-
-
-
- - - +
+

Users

+
+ - {isDeleteModalOpen && ( +
+
+ +
+ +
+ + {/* Existing Modals */} + + + {/* Delete Confirmation Modal */} + {isDeleteModalOpen && (
= ({
)} - - {renderPagination()} -
); }; diff --git a/ui/litellm-dashboard/src/components/view_users/columns.tsx b/ui/litellm-dashboard/src/components/view_users/columns.tsx new file mode 100644 index 0000000000..7879df4fe8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_users/columns.tsx @@ -0,0 +1,110 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { Badge, Grid, Icon } from "@tremor/react"; +import { Tooltip } from "antd"; +import { UserInfo } from "./types"; +import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; + +export const columns = ( + possibleUIRoles: Record>, + handleEdit: (user: UserInfo) => void, + handleDelete: (userId: string) => void, +): ColumnDef[] => [ + { + header: "User ID", + accessorKey: "user_id", + cell: ({ row }) => ( + + {row.original.user_id ? `${row.original.user_id.slice(0, 4)}...` : "-"} + + ), + }, + { + header: "User Email", + accessorKey: "user_email", + cell: ({ row }) => ( + {row.original.user_email || "-"} + ), + }, + { + header: "Role", + accessorKey: "user_role", + cell: ({ row }) => ( + + {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"} + + ), + }, + { + header: "User Spend ($ USD)", + accessorKey: "spend", + cell: ({ row }) => ( + + {row.original.spend ? row.original.spend.toFixed(2) : "-"} + + ), + }, + { + header: "User Max Budget ($ USD)", + accessorKey: "max_budget", + cell: ({ row }) => ( + + {row.original.max_budget !== null ? row.original.max_budget : "Unlimited"} + + ), + }, + { + header: "API Keys", + accessorKey: "key_count", + cell: ({ row }) => ( + + {row.original.key_count > 0 ? ( + + {row.original.key_count} Keys + + ) : ( + + No Keys + + )} + + ), + }, + { + header: "Created At", + accessorKey: "created_at", + sortingFn: "datetime", + cell: ({ row }) => ( + + {row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"} + + ), + }, + { + header: "Updated At", + accessorKey: "updated_at", + sortingFn: "datetime", + cell: ({ row }) => ( + + {row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( +
+ handleEdit(row.original)} + /> + handleDelete(row.original.user_id)} + /> +
+ ), + }, +]; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/view_users/table.tsx b/ui/litellm-dashboard/src/components/view_users/table.tsx new file mode 100644 index 0000000000..c426b42127 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_users/table.tsx @@ -0,0 +1,133 @@ +import { Fragment } from "react"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import React from "react"; +import { + Table, + TableHead, + TableHeaderCell, + TableBody, + TableRow, + TableCell, +} from "@tremor/react"; +import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; +import { UserInfo } from "./types"; + +interface UserDataTableProps { + data: UserInfo[]; + columns: ColumnDef[]; + isLoading?: boolean; +} + +export function UserDataTable({ + data = [], + columns, + isLoading = false, +}: UserDataTableProps) { + const [sorting, setSorting] = React.useState([ + { id: "created_at", desc: true } + ]); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+
+ {header.isPlaceholder ? null : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} +
+ {header.id !== 'actions' && ( +
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+ )} +
+
+ ))} +
+ ))} +
+ + {isLoading ? ( + + +
+

🚅 Loading users...

+
+
+
+ ) : data.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + +
+

No users found

+
+
+
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/view_users/types.ts b/ui/litellm-dashboard/src/components/view_users/types.ts new file mode 100644 index 0000000000..bcb5294a3b --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_users/types.ts @@ -0,0 +1,10 @@ +export interface UserInfo { + user_id: string; + user_email: string; + user_role: string; + spend: number; + max_budget: number | null; + key_count: number; + created_at: string; + updated_at: string; +} \ No newline at end of file