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. Add your users' information to the spreadsheet
  3. Save the file and upload it here
  4. After creation, download the results file containing the API keys for each user

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;