(UI) Fixes for managing Internal Users (#8786)

* allow bulk adding internal users

* allow sorting users by created at

* cleanup

* clean up user table

* show total num users

* show per user error when bulk adding users

* fix - don't allow creating duplicate internal users in DB

* ui flow fix for bulk adding users

* allow adding user in multiple teams with models

* correctly extract info

* working invitation link

* fix fill in table after bulk add

* fix the results from creating new users in bulkd

* bulk invite users

* fix view user flow

* fix ui type errors

* fix type errors

* fix type errors
This commit is contained in:
Ishaan Jaff 2025-02-24 23:40:13 -08:00 committed by GitHub
parent 3a13d5419a
commit f8e43296fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1134 additions and 215 deletions

View file

@ -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,7 +166,15 @@ 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,
)
# Check for duplicate email
await _check_duplicate_user_email(data.user_email, prisma_client)
data_json = data.json() # type: ignore
data_json = _update_internal_new_user_params(data_json, data)
@ -146,7 +183,9 @@ async def new_user(
# 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
from litellm.proxy.management_endpoints.team_endpoints import (
team_member_add,
)
try:
await team_member_add(
@ -232,6 +271,11 @@ async def new_user(
budget_duration=response.get("budget_duration", None),
model_max_budget=response.get("model_max_budget", None),
)
except Exception as e:
verbose_proxy_logger.exception(
"/user/new: Exception occured - {}".format(str(e))
)
raise handle_exception_on_proxy(e)
@router.get(

View file

@ -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",

View file

@ -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",

View file

@ -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<string, Record<string, string>>;
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<BulkCreateUsersProps> = ({
accessToken,
teams,
possibleUIRoles,
onUsersCreated,
}) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [parsedData, setParsedData] = useState<UserData[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [uiSettings, setUISettings] = useState<UISettings | null>(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 (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Invalid</span>
</div>
{record.error && (
<span className="text-sm text-red-500 ml-7">{record.error}</span>
)}
</div>
);
}
if (!record.status || record.status === 'pending') {
return <span className="text-gray-500">Pending</span>;
}
if (record.status === 'success') {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<span className="text-green-500">Success</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-gray-500 truncate max-w-[150px]">
{record.invitation_link}
</span>
<CopyToClipboard
text={record.invitation_link}
onCopy={() => message.success("Invitation link copied!")}
>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">
Copy
</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Failed</span>
</div>
{record.error && (
<span className="text-sm text-red-500 ml-7">
{JSON.stringify(record.error)}
</span>
)}
</div>
);
},
},
];
return (
<>
<TremorButton className="mx-auto mb-0" onClick={() => setIsModalVisible(true)}>
+ Bulk Invite Users
</TremorButton>
<Modal
title="Bulk Invite Users"
visible={isModalVisible}
width={800}
onCancel={() => setIsModalVisible(false)}
bodyStyle={{ maxHeight: '70vh', overflow: 'auto' }}
footer={null}
>
<div className="flex flex-col">
{/* Step indicator */}
{parsedData.length === 0 ? (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">1</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users' information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the API keys for each user</li>
</ol>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">user_email</p>
<p className="text-sm text-gray-600">User's email address (required)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-gray-600">User's role (one of: "proxy_admin", "proxy_admin_view_only", "internal_user", "internal_user_view_only")</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-gray-600">Comma-separated team IDs (e.g., "team-1,team-2")</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., "100")</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-gray-600">Budget reset period (e.g., "30d", "1mo")</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-gray-600">Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4")</p>
</div>
</div>
</div>
</div>
<TremorButton
onClick={downloadTemplate}
size="lg"
className="w-full md:w-auto"
>
<DownloadOutlined className="mr-2" /> Download CSV Template
</TremorButton>
</div>
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">2</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
</div>
<div className="ml-11">
<Upload
beforeUpload={handleFileUpload}
accept=".csv"
maxCount={1}
showUploadList={false}
>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer">
<UploadOutlined className="text-3xl text-gray-400 mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-gray-500 mb-3">or</p>
<TremorButton size="sm">Browse files</TremorButton>
</div>
</Upload>
</div>
</div>
) : (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">3</div>
<h3 className="text-lg font-medium">
{parsedData.some(user => user.status === 'success' || user.status === 'failed')
? "User Creation Results"
: "Review and create users"}
</h3>
</div>
{parseError && (
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<Text className="text-red-600 font-medium">{parseError}</Text>
</div>
)}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
{parsedData.some(user => user.status === 'success' || user.status === 'failed') ? (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">Creation Summary</Text>
<Text className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded mr-2">
{parsedData.filter(d => d.status === 'success').length} Successful
</Text>
{parsedData.some(d => d.status === 'failed') && (
<Text className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded">
{parsedData.filter(d => d.status === 'failed').length} Failed
</Text>
)}
</div>
) : (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">User Preview</Text>
<Text className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{parsedData.filter(d => d.isValid).length} of {parsedData.length} users valid
</Text>
</div>
)}
</div>
{!parsedData.some(user => user.status === 'success' || user.status === 'failed') && (
<div className="flex space-x-3">
<TremorButton
onClick={() => {
setParsedData([]);
setParseError(null);
}}
variant="secondary"
>
Back
</TremorButton>
<TremorButton
onClick={handleBulkCreate}
disabled={parsedData.filter(d => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? 'Creating...' : `Create ${parsedData.filter(d => d.isValid).length} Users`}
</TremorButton>
</div>
)}
</div>
{parsedData.some(user => user.status === 'success') && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
</div>
<div>
<Text className="font-medium text-blue-800">User creation complete</Text>
<Text className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing API keys and invitation links.
Users will need these API keys to make LLM requests through LiteLLM.
</Text>
</div>
</div>
</div>
)}
<Table
dataSource={parsedData}
columns={columns}
size="small"
pagination={{ pageSize: 5 }}
scroll={{ y: 300 }}
rowClassName={(record) => !record.isValid ? 'bg-red-50' : ''}
/>
{!parsedData.some(user => user.status === 'success' || user.status === 'failed') && (
<div className="flex justify-end mt-4">
<TremorButton
onClick={() => {
setParsedData([]);
setParseError(null);
}}
variant="secondary"
className="mr-3"
>
Back
</TremorButton>
<TremorButton
onClick={handleBulkCreate}
disabled={parsedData.filter(d => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? 'Creating...' : `Create ${parsedData.filter(d => d.isValid).length} Users`}
</TremorButton>
</div>
)}
{parsedData.some(user => user.status === 'success' || user.status === 'failed') && (
<div className="flex justify-end mt-4">
<TremorButton
onClick={() => {
setParsedData([]);
setParseError(null);
}}
variant="secondary"
className="mr-3"
>
Start New Bulk Import
</TremorButton>
<TremorButton
onClick={downloadResults}
variant="primary"
className="flex items-center"
>
<DownloadOutlined className="mr-2" /> Download User Credentials
</TremorButton>
</div>
)}
</div>
</div>
)}
</div>
</Modal>
</>
);
};
export default BulkCreateUsersButton;

View file

@ -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<CreateuserProps> = ({
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 (
<div>
<div className="flex gap-2">
<Button2 className="mx-auto mb-0" onClick={() => setIsModalVisible(true)}>
+ Invite User
</Button2>
<BulkCreateUsers
accessToken={accessToken}
teams={teams}
possibleUIRoles={possibleUIRoles}
/>
<Modal
title="Invite User"
visible={isModalVisible}

View file

@ -542,7 +542,7 @@ export const userCreateCall = async (
const errorData = await response.text();
handleError(errorData);
console.error("Error response from the server:", errorData);
throw new Error("Network response was not ok");
throw new Error(errorData);
}
const data = await response.json();

View file

@ -45,6 +45,10 @@ import {
} from "@heroicons/react/outline";
import { userDeleteCall } from "./networking";
import { columns } from "./view_users/columns";
import { UserDataTable } from "./view_users/table";
import { UserInfo } from "./view_users/types";
import BulkCreateUsers from "./bulk_create_users_button";
interface ViewUserDashboardProps {
accessToken: string | null;
@ -64,6 +68,14 @@ interface UserListResponse {
total_pages: number;
}
interface CreateuserProps {
userID: string;
accessToken: string;
teams: any[];
possibleUIRoles: Record<string, Record<string, string>>;
onUserCreated: () => Promise<void>;
}
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<ViewUserDashboardProps> = ({
const [openDialogId, setOpenDialogId] = React.useState<null | number>(null);
const [selectedItem, setSelectedItem] = useState<null | any>(null);
const [editModalVisible, setEditModalVisible] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
const [possibleUIRoles, setPossibleUIRoles] = useState<
Record<string, Record<string, string>>
>({});
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<ViewUserDashboardProps> = ({
// 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,126 +262,38 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
return <div>Loading...</div>;
}
function renderPagination() {
if (!userData) return null;
const totalPages = userListResponse?.total_pages || 0;
const handlePageChange = (newPage: number) => {
setUserData([]); // Clear current users
setCurrentPage(newPage);
};
return (
<div className="flex justify-between items-center">
<div>
Showing Page {currentPage } of {totalPages}
</div>
<div className="flex">
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-l focus:outline-none"
disabled={currentPage === 1}
onClick={() => handlePageChange(currentPage - 1)}
>
&larr; Prev
</button>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-r focus:outline-none"
disabled={currentPage === totalPages}
onClick={() => handlePageChange(currentPage + 1)}
>
Next &rarr;
</button>
</div>
</div>
const tableColumns = columns(
possibleUIRoles,
(user) => {
setSelectedUser(user);
setEditModalVisible(true);
},
handleDelete
);
}
return (
<div style={{ width: "100%" }}>
<Grid className="gap-2 p-2 h-[90vh] w-full mt-8">
<div className="w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Users</h1>
<div className="flex space-x-3">
<CreateUser
userID={userID}
accessToken={accessToken}
teams={teams}
possibleUIRoles={possibleUIRoles}
/>
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[90vh] mb-4">
<div className="mb-4 mt-1"></div>
<TabGroup>
<TabPanels>
<TabPanel>
<Table className="mt-5">
<TableHead>
<TableRow>
<TableHeaderCell>User ID</TableHeaderCell>
<TableHeaderCell>User Email</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>User Spend ($ USD)</TableHeaderCell>
<TableHeaderCell>User Max Budget ($ USD)</TableHeaderCell>
<TableHeaderCell>API Keys</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{userData.map((user: any) => (
<TableRow key={user.user_id}>
<TableCell>{user.user_id || "-"}</TableCell>
<TableCell>{user.user_email || "-"}</TableCell>
<TableCell>
{possibleUIRoles?.[user?.user_role]?.ui_label || "-"}
</TableCell>
<TableCell>
{user.spend ? user.spend?.toFixed(2) : "-"}
</TableCell>
<TableCell>
{user.max_budget !== null ? user.max_budget : "Unlimited"}
</TableCell>
<TableCell>
<Grid numItems={2}>
{user.key_count > 0 ? (
<Badge size={"xs"} color={"indigo"}>
{user.key_count} Keys
</Badge>
) : (
<Badge size={"xs"} color={"gray"}>
No Keys
</Badge>
)}
{/* <Text>{user.key_aliases.filter(key => key !== null).length} Keys</Text> */}
</Grid>
</TableCell>
<TableCell>
<Icon
icon={PencilAltIcon}
onClick={() => {
setSelectedUser(user);
setEditModalVisible(true);
}}
>
View Keys
</Icon>
<Icon
icon={TrashIcon}
onClick={() => handleDelete(user.user_id)}
>
Delete
</Icon>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabPanel>
<TabPanel>
<div className="flex items-center">
<div className="flex-1"></div>
<div className="flex-1 flex justify-between items-center"></div>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
<div className="bg-white rounded-lg shadow">
<UserDataTable
data={userData || []}
columns={tableColumns}
isLoading={!userData}
/>
</div>
{/* Existing Modals */}
<EditUserModal
visible={editModalVisible}
possibleUIRoles={possibleUIRoles}
@ -348,6 +301,8 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
user={selectedUser}
onSubmit={handleEditSubmit}
/>
{/* Delete Confirmation Modal */}
{isDeleteModalOpen && (
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
@ -395,9 +350,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
</div>
</div>
)}
</Card>
{renderPagination()}
</Grid>
</div>
);
};

View file

@ -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<string, Record<string, string>>,
handleEdit: (user: UserInfo) => void,
handleDelete: (userId: string) => void,
): ColumnDef<UserInfo>[] => [
{
header: "User ID",
accessorKey: "user_id",
cell: ({ row }) => (
<Tooltip title={row.original.user_id}>
<span className="text-xs">{row.original.user_id ? `${row.original.user_id.slice(0, 4)}...` : "-"}</span>
</Tooltip>
),
},
{
header: "User Email",
accessorKey: "user_email",
cell: ({ row }) => (
<span className="text-xs">{row.original.user_email || "-"}</span>
),
},
{
header: "Role",
accessorKey: "user_role",
cell: ({ row }) => (
<span className="text-xs">
{possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}
</span>
),
},
{
header: "User Spend ($ USD)",
accessorKey: "spend",
cell: ({ row }) => (
<span className="text-xs">
{row.original.spend ? row.original.spend.toFixed(2) : "-"}
</span>
),
},
{
header: "User Max Budget ($ USD)",
accessorKey: "max_budget",
cell: ({ row }) => (
<span className="text-xs">
{row.original.max_budget !== null ? row.original.max_budget : "Unlimited"}
</span>
),
},
{
header: "API Keys",
accessorKey: "key_count",
cell: ({ row }) => (
<Grid numItems={2}>
{row.original.key_count > 0 ? (
<Badge size="xs" color="indigo">
{row.original.key_count} Keys
</Badge>
) : (
<Badge size="xs" color="gray">
No Keys
</Badge>
)}
</Grid>
),
},
{
header: "Created At",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => (
<span className="text-xs">
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"}
</span>
),
},
{
header: "Updated At",
accessorKey: "updated_at",
sortingFn: "datetime",
cell: ({ row }) => (
<span className="text-xs">
{row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex gap-2">
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => handleEdit(row.original)}
/>
<Icon
icon={TrashIcon}
size="sm"
onClick={() => handleDelete(row.original.user_id)}
/>
</div>
),
},
];

View file

@ -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<UserInfo, any>[];
isLoading?: boolean;
}
export function UserDataTable({
data = [],
columns,
isLoading = false,
}: UserDataTableProps) {
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "created_at", desc: true }
]);
const table = useReactTable({
data,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === 'actions'
? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]'
: ''
}`}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
</div>
{header.id !== 'actions' && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>🚅 Loading users...</p>
</div>
</TableCell>
</TableRow>
) : data.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === 'actions'
? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]'
: ''
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No users found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -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;
}