mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
(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:
parent
3a13d5419a
commit
f8e43296fb
10 changed files with 1134 additions and 215 deletions
|
@ -15,7 +15,7 @@ import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
import fastapi
|
import fastapi
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
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
|
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(
|
@router.post(
|
||||||
"/user/new",
|
"/user/new",
|
||||||
tags=["Internal User management"],
|
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
|
# Check for duplicate email
|
||||||
data_json = _update_internal_new_user_params(data_json, data)
|
await _check_duplicate_user_email(data.user_email, prisma_client)
|
||||||
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:
|
data_json = data.json() # type: ignore
|
||||||
await team_member_add(
|
data_json = _update_internal_new_user_params(data_json, data)
|
||||||
data=TeamMemberAddRequest(
|
response = await generate_key_helper_fn(request_type="user", **data_json)
|
||||||
team_id=data_json.get("team_id", None),
|
# Admin UI Logic
|
||||||
member=Member(
|
# Add User to Team and Organization
|
||||||
user_id=data_json.get("user_id", None),
|
# if team_id passed add this user to the team
|
||||||
role="user",
|
if data_json.get("team_id", None) is not None:
|
||||||
user_email=data_json.get("user_email", 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(
|
||||||
http_request=Request(
|
scope={"type": "http", "path": "/user/new"},
|
||||||
scope={"type": "http", "path": "/user/new"},
|
),
|
||||||
),
|
user_api_key_dict=user_api_key_dict,
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
except HTTPException as e:
|
||||||
verbose_proxy_logger.debug(
|
if e.status_code == 400 and (
|
||||||
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): Exception occured - {}".format(
|
"already exists" in str(e) or "doesn't exist" in str(e)
|
||||||
str(e)
|
):
|
||||||
|
verbose_proxy_logger.debug(
|
||||||
|
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): User already exists in team - {}".format(
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
else:
|
||||||
except Exception as e:
|
verbose_proxy_logger.debug(
|
||||||
if "already exists" in str(e) or "doesn't exist" in str(e):
|
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): Exception occured - {}".format(
|
||||||
verbose_proxy_logger.debug(
|
str(e)
|
||||||
"litellm.proxy.management_endpoints.internal_user_endpoints.new_user(): User already exists in team - {}".format(
|
)
|
||||||
str(e)
|
|
||||||
)
|
)
|
||||||
)
|
except Exception as e:
|
||||||
else:
|
if "already exists" in str(e) or "doesn't exist" in str(e):
|
||||||
raise 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:
|
if data.send_invite_email is True:
|
||||||
# check if user has setup email alerting
|
# check if user has setup email alerting
|
||||||
if "email" not in general_settings.get("alerting", []):
|
if "email" not in general_settings.get("alerting", []):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Email alerting not setup on config.yaml. Please set `alerting=['email']. \nDocs: https://docs.litellm.ai/docs/proxy/email`"
|
"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(
|
# If user configured email alerting - send an Email letting their end-user know the key was created
|
||||||
event="internal_user_created",
|
asyncio.create_task(
|
||||||
event_group="internal_user",
|
proxy_logging_obj.slack_alerting_instance.send_key_created_or_user_invited_email(
|
||||||
event_message="Welcome to LiteLLM Proxy",
|
webhook_event=event,
|
||||||
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),
|
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_email=response.get("user_email", None),
|
||||||
team_id=response.get("team_id", "Default Team"),
|
user_alias=response.get("user_alias", None),
|
||||||
key_alias=response.get("key_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),
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
# If user configured email alerting - send an Email letting their end-user know the key was created
|
verbose_proxy_logger.exception(
|
||||||
asyncio.create_task(
|
"/user/new: Exception occured - {}".format(str(e))
|
||||||
proxy_logging_obj.slack_alerting_instance.send_key_created_or_user_invited_email(
|
|
||||||
webhook_event=event,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
raise handle_exception_on_proxy(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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
15
ui/litellm-dashboard/package-lock.json
generated
15
ui/litellm-dashboard/package-lock.json
generated
|
@ -15,6 +15,7 @@
|
||||||
"@tanstack/react-query": "^5.64.1",
|
"@tanstack/react-query": "^5.64.1",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tremor/react": "^3.13.3",
|
"@tremor/react": "^3.13.3",
|
||||||
|
"@types/papaparse": "^5.3.15",
|
||||||
"antd": "^5.13.2",
|
"antd": "^5.13.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
@ -22,6 +23,7 @@
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
|
"papaparse": "^5.5.2",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
@ -981,6 +983,14 @@
|
||||||
"form-data": "^4.0.0"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.11",
|
"version": "15.7.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
||||||
|
@ -5334,6 +5344,11 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"@tanstack/react-query": "^5.64.1",
|
"@tanstack/react-query": "^5.64.1",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tremor/react": "^3.13.3",
|
"@tremor/react": "^3.13.3",
|
||||||
|
"@types/papaparse": "^5.3.15",
|
||||||
"antd": "^5.13.2",
|
"antd": "^5.13.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
@ -23,6 +24,7 @@
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
|
"papaparse": "^5.5.2",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
|
645
ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
Normal file
645
ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
Normal 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;
|
|
@ -19,6 +19,7 @@ import {
|
||||||
invitationCreateCall,
|
invitationCreateCall,
|
||||||
getProxyUISettings,
|
getProxyUISettings,
|
||||||
} from "./networking";
|
} from "./networking";
|
||||||
|
import BulkCreateUsers from "./bulk_create_users_button";
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
interface CreateuserProps {
|
interface CreateuserProps {
|
||||||
|
@ -148,16 +149,23 @@ const Createuser: React.FC<CreateuserProps> = ({
|
||||||
message.success("API user Created");
|
message.success("API user Created");
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
localStorage.removeItem("userData" + userID);
|
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);
|
console.error("Error creating the user:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex gap-2">
|
||||||
<Button2 className="mx-auto mb-0" onClick={() => setIsModalVisible(true)}>
|
<Button2 className="mx-auto mb-0" onClick={() => setIsModalVisible(true)}>
|
||||||
+ Invite User
|
+ Invite User
|
||||||
</Button2>
|
</Button2>
|
||||||
|
<BulkCreateUsers
|
||||||
|
accessToken={accessToken}
|
||||||
|
teams={teams}
|
||||||
|
possibleUIRoles={possibleUIRoles}
|
||||||
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="Invite User"
|
title="Invite User"
|
||||||
visible={isModalVisible}
|
visible={isModalVisible}
|
||||||
|
|
|
@ -542,7 +542,7 @@ export const userCreateCall = async (
|
||||||
const errorData = await response.text();
|
const errorData = await response.text();
|
||||||
handleError(errorData);
|
handleError(errorData);
|
||||||
console.error("Error response from the server:", 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();
|
const data = await response.json();
|
||||||
|
|
|
@ -45,6 +45,10 @@ import {
|
||||||
} from "@heroicons/react/outline";
|
} from "@heroicons/react/outline";
|
||||||
|
|
||||||
import { userDeleteCall } from "./networking";
|
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 {
|
interface ViewUserDashboardProps {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
@ -64,6 +68,14 @@ interface UserListResponse {
|
||||||
total_pages: number;
|
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 isLocal = process.env.NODE_ENV === "development";
|
||||||
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
|
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
|
||||||
if (isLocal != true) {
|
if (isLocal != true) {
|
||||||
|
@ -86,13 +98,14 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
const [openDialogId, setOpenDialogId] = React.useState<null | number>(null);
|
const [openDialogId, setOpenDialogId] = React.useState<null | number>(null);
|
||||||
const [selectedItem, setSelectedItem] = useState<null | any>(null);
|
const [selectedItem, setSelectedItem] = useState<null | any>(null);
|
||||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState(null);
|
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||||
const [possibleUIRoles, setPossibleUIRoles] = useState<
|
const [possibleUIRoles, setPossibleUIRoles] = useState<
|
||||||
Record<string, Record<string, string>>
|
Record<string, Record<string, string>>
|
||||||
>({});
|
>({});
|
||||||
const defaultPageSize = 25;
|
const defaultPageSize = 25;
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
// check if window is not undefined
|
// check if window is not undefined
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
@ -160,6 +173,34 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
// Close the modal
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!accessToken || !token || !userRole || !userID) {
|
if (!accessToken || !token || !userRole || !userID) {
|
||||||
return;
|
return;
|
||||||
|
@ -221,134 +262,48 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPagination() {
|
const tableColumns = columns(
|
||||||
if (!userData) return null;
|
possibleUIRoles,
|
||||||
|
(user) => {
|
||||||
const totalPages = userListResponse?.total_pages || 0;
|
setSelectedUser(user);
|
||||||
|
setEditModalVisible(true);
|
||||||
const handlePageChange = (newPage: number) => {
|
},
|
||||||
setUserData([]); // Clear current users
|
handleDelete
|
||||||
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)}
|
|
||||||
>
|
|
||||||
← 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 →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%" }}>
|
<div className="w-full p-6">
|
||||||
<Grid className="gap-2 p-2 h-[90vh] w-full mt-8">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<CreateUser
|
<h1 className="text-xl font-semibold">Users</h1>
|
||||||
userID={userID}
|
<div className="flex space-x-3">
|
||||||
accessToken={accessToken}
|
<CreateUser
|
||||||
teams={teams}
|
userID={userID}
|
||||||
possibleUIRoles={possibleUIRoles}
|
accessToken={accessToken}
|
||||||
/>
|
teams={teams}
|
||||||
<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>
|
|
||||||
<EditUserModal
|
|
||||||
visible={editModalVisible}
|
|
||||||
possibleUIRoles={possibleUIRoles}
|
possibleUIRoles={possibleUIRoles}
|
||||||
onCancel={handleEditCancel}
|
|
||||||
user={selectedUser}
|
|
||||||
onSubmit={handleEditSubmit}
|
|
||||||
/>
|
/>
|
||||||
{isDeleteModalOpen && (
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<UserDataTable
|
||||||
|
data={userData || []}
|
||||||
|
columns={tableColumns}
|
||||||
|
isLoading={!userData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Modals */}
|
||||||
|
<EditUserModal
|
||||||
|
visible={editModalVisible}
|
||||||
|
possibleUIRoles={possibleUIRoles}
|
||||||
|
onCancel={handleEditCancel}
|
||||||
|
user={selectedUser}
|
||||||
|
onSubmit={handleEditSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{isDeleteModalOpen && (
|
||||||
<div className="fixed z-10 inset-0 overflow-y-auto">
|
<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">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div
|
<div
|
||||||
|
@ -395,9 +350,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
{renderPagination()}
|
|
||||||
</Grid>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
110
ui/litellm-dashboard/src/components/view_users/columns.tsx
Normal file
110
ui/litellm-dashboard/src/components/view_users/columns.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
133
ui/litellm-dashboard/src/components/view_users/table.tsx
Normal file
133
ui/litellm-dashboard/src/components/view_users/table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
10
ui/litellm-dashboard/src/components/view_users/types.ts
Normal file
10
ui/litellm-dashboard/src/components/view_users/types.ts
Normal 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;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue