mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-24 18:24:20 +00:00
Merge branch 'main' into litellm_contributors_apr_22
This commit is contained in:
commit
b35c5f9d30
13 changed files with 558 additions and 88 deletions
File diff suppressed because one or more lines are too long
|
@ -687,6 +687,8 @@ class GenerateKeyResponse(KeyRequestBase):
|
||||||
token: Optional[str] = None
|
token: Optional[str] = None
|
||||||
created_by: Optional[str] = None
|
created_by: Optional[str] = None
|
||||||
updated_by: Optional[str] = None
|
updated_by: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
@ -43,6 +43,9 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||||
SpendAnalyticsPaginatedResponse,
|
SpendAnalyticsPaginatedResponse,
|
||||||
SpendMetrics,
|
SpendMetrics,
|
||||||
)
|
)
|
||||||
|
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
|
||||||
|
UserListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
@ -899,15 +902,11 @@ async def get_user_key_counts(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/user/get_users",
|
|
||||||
tags=["Internal User management"],
|
|
||||||
dependencies=[Depends(user_api_key_auth)],
|
|
||||||
)
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/user/list",
|
"/user/list",
|
||||||
tags=["Internal User management"],
|
tags=["Internal User management"],
|
||||||
dependencies=[Depends(user_api_key_auth)],
|
dependencies=[Depends(user_api_key_auth)],
|
||||||
|
response_model=UserListResponse,
|
||||||
)
|
)
|
||||||
async def get_users(
|
async def get_users(
|
||||||
role: Optional[str] = fastapi.Query(
|
role: Optional[str] = fastapi.Query(
|
||||||
|
@ -916,15 +915,19 @@ async def get_users(
|
||||||
user_ids: Optional[str] = fastapi.Query(
|
user_ids: Optional[str] = fastapi.Query(
|
||||||
default=None, description="Get list of users by user_ids"
|
default=None, description="Get list of users by user_ids"
|
||||||
),
|
),
|
||||||
|
user_email: Optional[str] = fastapi.Query(
|
||||||
|
default=None, description="Filter users by partial email match"
|
||||||
|
),
|
||||||
|
team: Optional[str] = fastapi.Query(
|
||||||
|
default=None, description="Filter users by team id"
|
||||||
|
),
|
||||||
page: int = fastapi.Query(default=1, ge=1, description="Page number"),
|
page: int = fastapi.Query(default=1, ge=1, description="Page number"),
|
||||||
page_size: int = fastapi.Query(
|
page_size: int = fastapi.Query(
|
||||||
default=25, ge=1, le=100, description="Number of items per page"
|
default=25, ge=1, le=100, description="Number of items per page"
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get a paginated list of users, optionally filtered by role.
|
Get a paginated list of users with filtering options.
|
||||||
|
|
||||||
Used by the UI to populate the user lists.
|
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
role: Optional[str]
|
role: Optional[str]
|
||||||
|
@ -935,17 +938,17 @@ async def get_users(
|
||||||
- internal_user_viewer
|
- internal_user_viewer
|
||||||
user_ids: Optional[str]
|
user_ids: Optional[str]
|
||||||
Get list of users by user_ids. Comma separated list of user_ids.
|
Get list of users by user_ids. Comma separated list of user_ids.
|
||||||
|
user_email: Optional[str]
|
||||||
|
Filter users by partial email match
|
||||||
|
team: Optional[str]
|
||||||
|
Filter users by team id. Will match if user has this team in their teams array.
|
||||||
page: int
|
page: int
|
||||||
The page number to return
|
The page number to return
|
||||||
page_size: int
|
page_size: int
|
||||||
The number of items per page
|
The number of items per page
|
||||||
|
|
||||||
Currently - admin-only endpoint.
|
Returns:
|
||||||
|
UserListResponse with filtered and paginated users
|
||||||
Example curl:
|
|
||||||
```
|
|
||||||
http://0.0.0.0:4000/user/list?user_ids=default_user_id,693c1a4a-1cc0-4c7c-afe8-b5d2c8d52e17
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
from litellm.proxy.proxy_server import prisma_client
|
from litellm.proxy.proxy_server import prisma_client
|
||||||
|
|
||||||
|
@ -958,25 +961,32 @@ async def get_users(
|
||||||
# Calculate skip and take for pagination
|
# Calculate skip and take for pagination
|
||||||
skip = (page - 1) * page_size
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
# Prepare the query conditions
|
|
||||||
# Build where conditions based on provided parameters
|
# Build where conditions based on provided parameters
|
||||||
where_conditions: Dict[str, Any] = {}
|
where_conditions: Dict[str, Any] = {}
|
||||||
|
|
||||||
if role:
|
if role:
|
||||||
where_conditions["user_role"] = {
|
where_conditions["user_role"] = role # Exact match instead of contains
|
||||||
"contains": role,
|
|
||||||
"mode": "insensitive", # Case-insensitive search
|
|
||||||
}
|
|
||||||
|
|
||||||
if user_ids and isinstance(user_ids, str):
|
if user_ids and isinstance(user_ids, str):
|
||||||
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
|
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
|
||||||
where_conditions["user_id"] = {
|
where_conditions["user_id"] = {
|
||||||
"in": user_id_list, # Now passing a list of strings as required by Prisma
|
"in": user_id_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
users: Optional[
|
if user_email is not None and isinstance(user_email, str):
|
||||||
List[LiteLLM_UserTable]
|
where_conditions["user_email"] = {
|
||||||
] = await prisma_client.db.litellm_usertable.find_many(
|
"contains": user_email,
|
||||||
|
"mode": "insensitive", # Case-insensitive search
|
||||||
|
}
|
||||||
|
|
||||||
|
if team is not None and isinstance(team, str):
|
||||||
|
where_conditions["teams"] = {
|
||||||
|
"has": team # Array contains for string arrays in Prisma
|
||||||
|
}
|
||||||
|
|
||||||
|
## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}}
|
||||||
|
where_conditions = {k: v for k, v in where_conditions.items() if v is not None}
|
||||||
|
users = await prisma_client.db.litellm_usertable.find_many(
|
||||||
where=where_conditions,
|
where=where_conditions,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
take=page_size,
|
take=page_size,
|
||||||
|
@ -984,9 +994,7 @@ async def get_users(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total count of user rows
|
# Get total count of user rows
|
||||||
total_count = await prisma_client.db.litellm_usertable.count(
|
total_count = await prisma_client.db.litellm_usertable.count(where=where_conditions)
|
||||||
where=where_conditions # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get key count for each user
|
# Get key count for each user
|
||||||
if users is not None:
|
if users is not None:
|
||||||
|
@ -1009,7 +1017,7 @@ async def get_users(
|
||||||
LiteLLM_UserTableWithKeyCount(
|
LiteLLM_UserTableWithKeyCount(
|
||||||
**user.model_dump(), key_count=user_key_counts.get(user.user_id, 0)
|
**user.model_dump(), key_count=user_key_counts.get(user.user_id, 0)
|
||||||
)
|
)
|
||||||
) # Return full key object
|
)
|
||||||
else:
|
else:
|
||||||
user_list = []
|
user_list = []
|
||||||
|
|
||||||
|
|
|
@ -1347,10 +1347,13 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
||||||
create_key_response = await prisma_client.insert_data(
|
create_key_response = await prisma_client.insert_data(
|
||||||
data=key_data, table_name="key"
|
data=key_data, table_name="key"
|
||||||
)
|
)
|
||||||
|
|
||||||
key_data["token_id"] = getattr(create_key_response, "token", None)
|
key_data["token_id"] = getattr(create_key_response, "token", None)
|
||||||
key_data["litellm_budget_table"] = getattr(
|
key_data["litellm_budget_table"] = getattr(
|
||||||
create_key_response, "litellm_budget_table", None
|
create_key_response, "litellm_budget_table", None
|
||||||
)
|
)
|
||||||
|
key_data["created_at"] = getattr(create_key_response, "created_at", None)
|
||||||
|
key_data["updated_at"] = getattr(create_key_response, "updated_at", None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
verbose_proxy_logger.error(
|
verbose_proxy_logger.error(
|
||||||
"litellm.proxy.proxy_server.generate_key_helper_fn(): Exception occured - {}".format(
|
"litellm.proxy.proxy_server.generate_key_helper_fn(): Exception occured - {}".format(
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
from typing import Any, Dict, List, Literal, Optional, Union
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from litellm.proxy._types import LiteLLM_UserTableWithKeyCount
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response model for the user list endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
users: List[LiteLLM_UserTableWithKeyCount]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
|
@ -38,6 +38,7 @@ from litellm.integrations.langfuse.langfuse_prompt_management import (
|
||||||
LangfusePromptManagement,
|
LangfusePromptManagement,
|
||||||
)
|
)
|
||||||
from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLogger
|
from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLogger
|
||||||
|
from litellm.integrations.agentops import AgentOps
|
||||||
from litellm.integrations.humanloop import HumanloopLogger
|
from litellm.integrations.humanloop import HumanloopLogger
|
||||||
from litellm.proxy.hooks.dynamic_rate_limiter import _PROXY_DynamicRateLimitHandler
|
from litellm.proxy.hooks.dynamic_rate_limiter import _PROXY_DynamicRateLimitHandler
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
@ -75,6 +76,7 @@ callback_class_str_to_classType = {
|
||||||
"pagerduty": PagerDutyAlerting,
|
"pagerduty": PagerDutyAlerting,
|
||||||
"gcs_pubsub": GcsPubSubLogger,
|
"gcs_pubsub": GcsPubSubLogger,
|
||||||
"anthropic_cache_control_hook": AnthropicCacheControlHook,
|
"anthropic_cache_control_hook": AnthropicCacheControlHook,
|
||||||
|
"agentops": AgentOps,
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_env_vars = {
|
expected_env_vars = {
|
||||||
|
|
119
tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts
Normal file
119
tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
/*
|
||||||
|
Search Users in Admin UI
|
||||||
|
E2E Test for user search functionality
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. Navigate to Internal Users tab
|
||||||
|
2. Verify search input exists
|
||||||
|
3. Test search functionality
|
||||||
|
4. Verify results update
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("user search test", async ({ page }) => {
|
||||||
|
// Set a longer timeout for the entire test
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
// Enable console logging
|
||||||
|
page.on("console", (msg) => console.log("PAGE LOG:", msg.text()));
|
||||||
|
|
||||||
|
// Login first
|
||||||
|
await page.goto("http://localhost:4000/ui");
|
||||||
|
console.log("Navigated to login page");
|
||||||
|
|
||||||
|
// Wait for login form to be visible
|
||||||
|
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
|
||||||
|
console.log("Login form is visible");
|
||||||
|
|
||||||
|
await page.fill('input[name="username"]', "admin");
|
||||||
|
await page.fill('input[name="password"]', "gm");
|
||||||
|
console.log("Filled login credentials");
|
||||||
|
|
||||||
|
const loginButton = page.locator('input[type="submit"]');
|
||||||
|
await expect(loginButton).toBeEnabled();
|
||||||
|
await loginButton.click();
|
||||||
|
console.log("Clicked login button");
|
||||||
|
|
||||||
|
// Wait for navigation to complete and dashboard to load
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
console.log("Page loaded after login");
|
||||||
|
|
||||||
|
// Take a screenshot for debugging
|
||||||
|
await page.screenshot({ path: "after-login.png" });
|
||||||
|
console.log("Took screenshot after login");
|
||||||
|
|
||||||
|
// Try to find the Internal User tab with more debugging
|
||||||
|
console.log("Looking for Internal User tab...");
|
||||||
|
const internalUserTab = page.locator("span.ant-menu-title-content", {
|
||||||
|
hasText: "Internal User",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the tab to be visible
|
||||||
|
await internalUserTab.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
console.log("Internal User tab is visible");
|
||||||
|
|
||||||
|
// Take another screenshot before clicking
|
||||||
|
await page.screenshot({ path: "before-tab-click.png" });
|
||||||
|
console.log("Took screenshot before tab click");
|
||||||
|
|
||||||
|
await internalUserTab.click();
|
||||||
|
console.log("Clicked Internal User tab");
|
||||||
|
|
||||||
|
// Wait for the page to load and table to be visible
|
||||||
|
await page.waitForSelector("tbody tr", { timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000); // Additional wait for table to stabilize
|
||||||
|
console.log("Table is visible");
|
||||||
|
|
||||||
|
// Take a final screenshot
|
||||||
|
await page.screenshot({ path: "after-tab-click.png" });
|
||||||
|
console.log("Took screenshot after tab click");
|
||||||
|
|
||||||
|
// Verify search input exists
|
||||||
|
const searchInput = page.locator('input[placeholder="Search by email..."]');
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
console.log("Search input is visible");
|
||||||
|
|
||||||
|
// Test search functionality
|
||||||
|
const initialUserCount = await page.locator("tbody tr").count();
|
||||||
|
console.log(`Initial user count: ${initialUserCount}`);
|
||||||
|
|
||||||
|
// Perform a search
|
||||||
|
const testEmail = "test@";
|
||||||
|
await searchInput.fill(testEmail);
|
||||||
|
console.log("Filled search input");
|
||||||
|
|
||||||
|
// Wait for the debounced search to complete
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log("Waited for debounce");
|
||||||
|
|
||||||
|
// Wait for the results count to update
|
||||||
|
await page.waitForFunction((initialCount) => {
|
||||||
|
const currentCount = document.querySelectorAll("tbody tr").length;
|
||||||
|
return currentCount !== initialCount;
|
||||||
|
}, initialUserCount);
|
||||||
|
console.log("Results updated");
|
||||||
|
|
||||||
|
const filteredUserCount = await page.locator("tbody tr").count();
|
||||||
|
console.log(`Filtered user count: ${filteredUserCount}`);
|
||||||
|
|
||||||
|
expect(filteredUserCount).toBeDefined();
|
||||||
|
|
||||||
|
// Clear the search
|
||||||
|
await searchInput.clear();
|
||||||
|
console.log("Cleared search");
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log("Waited for debounce after clear");
|
||||||
|
|
||||||
|
await page.waitForFunction((initialCount) => {
|
||||||
|
const currentCount = document.querySelectorAll("tbody tr").length;
|
||||||
|
return currentCount === initialCount;
|
||||||
|
}, initialUserCount);
|
||||||
|
console.log("Results reset");
|
||||||
|
|
||||||
|
const resetUserCount = await page.locator("tbody tr").count();
|
||||||
|
console.log(`Reset user count: ${resetUserCount}`);
|
||||||
|
|
||||||
|
expect(resetUserCount).toBe(initialUserCount);
|
||||||
|
});
|
|
@ -2,45 +2,74 @@
|
||||||
Test view internal user page
|
Test view internal user page
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test('view internal user page', async ({ page }) => {
|
test("view internal user page", async ({ page }) => {
|
||||||
// Go to the specified URL
|
// Go to the specified URL
|
||||||
await page.goto('http://localhost:4000/ui');
|
await page.goto("http://localhost:4000/ui");
|
||||||
|
|
||||||
// Enter "admin" in the username input field
|
// Enter "admin" in the username input field
|
||||||
await page.fill('input[name="username"]', 'admin');
|
await page.fill('input[name="username"]', "admin");
|
||||||
|
|
||||||
// Enter "gm" in the password input field
|
// Enter "gm" in the password input field
|
||||||
await page.fill('input[name="password"]', 'gm');
|
await page.fill('input[name="password"]', "gm");
|
||||||
|
|
||||||
// Optionally, you can add an assertion to verify the login button is enabled
|
// Click the login button
|
||||||
const loginButton = page.locator('input[type="submit"]');
|
const loginButton = page.locator('input[type="submit"]');
|
||||||
await expect(loginButton).toBeEnabled();
|
await expect(loginButton).toBeEnabled();
|
||||||
|
|
||||||
// Optionally, you can click the login button to submit the form
|
|
||||||
await loginButton.click();
|
await loginButton.click();
|
||||||
|
|
||||||
const tabElement = page.locator('span.ant-menu-title-content', { hasText: 'Internal User' });
|
// Wait for the Internal User tab and click it
|
||||||
|
const tabElement = page.locator("span.ant-menu-title-content", {
|
||||||
|
hasText: "Internal User",
|
||||||
|
});
|
||||||
await tabElement.click();
|
await tabElement.click();
|
||||||
|
|
||||||
// try to click on button
|
// Wait for the table to load
|
||||||
// <button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-l focus:outline-none" disabled="">← Prev</button>
|
await page.waitForSelector("tbody tr", { timeout: 10000 });
|
||||||
// wait 1-2 seconds
|
await page.waitForTimeout(2000); // Additional wait for table to stabilize
|
||||||
await page.waitForTimeout(10000);
|
|
||||||
|
|
||||||
// Test all expected fields are present
|
// Test all expected fields are present
|
||||||
// number of keys owned by user
|
// number of keys owned by user
|
||||||
const keysBadges = page.locator('p.tremor-Badge-text.text-sm.whitespace-nowrap', { hasText: 'Keys' });
|
const keysBadges = page.locator(
|
||||||
const keysCountArray = await keysBadges.evaluateAll(elements => elements.map(el => parseInt(el.textContent.split(' ')[0], 10)));
|
"p.tremor-Badge-text.text-sm.whitespace-nowrap",
|
||||||
|
{ hasText: "Keys" }
|
||||||
|
);
|
||||||
|
const keysCountArray = await keysBadges.evaluateAll((elements) =>
|
||||||
|
elements.map((el) => {
|
||||||
|
const text = el.textContent;
|
||||||
|
return text ? parseInt(text.split(" ")[0], 10) : 0;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const hasNonZeroKeys = keysCountArray.some(count => count > 0);
|
const hasNonZeroKeys = keysCountArray.some((count) => count > 0);
|
||||||
expect(hasNonZeroKeys).toBe(true);
|
expect(hasNonZeroKeys).toBe(true);
|
||||||
|
|
||||||
// test pagination
|
// test pagination
|
||||||
const prevButton = page.locator('button.px-3.py-1.text-sm.border.rounded-md.hover\\:bg-gray-50.disabled\\:opacity-50.disabled\\:cursor-not-allowed', { hasText: 'Previous' });
|
// Wait for pagination controls to be visible
|
||||||
await expect(prevButton).toBeDisabled();
|
await page.waitForSelector(".flex.justify-between.items-center", {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
const nextButton = page.locator('button.px-3.py-1.text-sm.border.rounded-md.hover\\:bg-gray-50.disabled\\:opacity-50.disabled\\:cursor-not-allowed', { hasText: 'Next' });
|
// Check if we're on the first page by looking at the results count
|
||||||
await expect(nextButton).toBeEnabled();
|
const resultsText =
|
||||||
|
(await page.locator(".text-sm.text-gray-700").textContent()) || "";
|
||||||
|
const isFirstPage = resultsText.includes("1 -");
|
||||||
|
|
||||||
|
if (isFirstPage) {
|
||||||
|
// On first page, previous button should be disabled
|
||||||
|
const prevButton = page.locator("button", { hasText: "Previous" });
|
||||||
|
await expect(prevButton).toBeDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button should be enabled if there are more pages
|
||||||
|
const nextButton = page.locator("button", { hasText: "Next" });
|
||||||
|
const totalResults =
|
||||||
|
(await page.locator(".text-sm.text-gray-700").textContent()) || "";
|
||||||
|
const hasMorePages =
|
||||||
|
totalResults.includes("of") && !totalResults.includes("1 - 25 of 25");
|
||||||
|
|
||||||
|
if (hasMorePages) {
|
||||||
|
await expect(nextButton).toBeEnabled();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
8
tests/proxy_admin_ui_tests/test-results/.last-run.json
Normal file
8
tests/proxy_admin_ui_tests/test-results/.last-run.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"8306bf902634636ae770-183086b993a71bc98dd6",
|
||||||
|
"1bfc70f64c2dd4741dbb-58cd256736ebe53a2d97",
|
||||||
|
"ea1c46def20befad7a54-cb6c473c41474485b610"
|
||||||
|
]
|
||||||
|
}
|
|
@ -109,6 +109,18 @@ async def test_key_gen():
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_simple_key_gen():
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
key_data = await generate_key(session, i=0)
|
||||||
|
key = key_data["key"]
|
||||||
|
assert key_data["token"] is not None
|
||||||
|
assert key_data["token"] != key
|
||||||
|
assert key_data["token_id"] is not None
|
||||||
|
assert key_data["created_at"] is not None
|
||||||
|
assert key_data["updated_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_key_gen_bad_key():
|
async def test_key_gen_bad_key():
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -676,6 +676,9 @@ export const userListCall = async (
|
||||||
userIDs: string[] | null = null,
|
userIDs: string[] | null = null,
|
||||||
page: number | null = null,
|
page: number | null = null,
|
||||||
page_size: number | null = null,
|
page_size: number | null = null,
|
||||||
|
userEmail: string | null = null,
|
||||||
|
userRole: string | null = null,
|
||||||
|
team: string | null = null,
|
||||||
) => {
|
) => {
|
||||||
/**
|
/**
|
||||||
* Get all available teams on proxy
|
* Get all available teams on proxy
|
||||||
|
@ -698,6 +701,18 @@ export const userListCall = async (
|
||||||
if (page_size) {
|
if (page_size) {
|
||||||
queryParams.append('page_size', page_size.toString());
|
queryParams.append('page_size', page_size.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userEmail) {
|
||||||
|
queryParams.append('user_email', userEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRole) {
|
||||||
|
queryParams.append('role', userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
queryParams.append('team', team);
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
if (queryString) {
|
if (queryString) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Title,
|
Title,
|
||||||
|
@ -23,6 +23,7 @@ import {
|
||||||
DialogPanel,
|
DialogPanel,
|
||||||
Icon,
|
Icon,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
} from "@tremor/react";
|
} from "@tremor/react";
|
||||||
|
|
||||||
import { message } from "antd";
|
import { message } from "antd";
|
||||||
|
@ -32,6 +33,7 @@ import {
|
||||||
userInfoCall,
|
userInfoCall,
|
||||||
userUpdateUserCall,
|
userUpdateUserCall,
|
||||||
getPossibleUserRoles,
|
getPossibleUserRoles,
|
||||||
|
userListCall,
|
||||||
} from "./networking";
|
} from "./networking";
|
||||||
import { Badge, BadgeDelta, Button } from "@tremor/react";
|
import { Badge, BadgeDelta, Button } from "@tremor/react";
|
||||||
import RequestAccess from "./request_model_access";
|
import RequestAccess from "./request_model_access";
|
||||||
|
@ -50,6 +52,7 @@ import { UserDataTable } from "./view_users/table";
|
||||||
import { UserInfo } from "./view_users/types";
|
import { UserInfo } from "./view_users/types";
|
||||||
import BulkCreateUsers from "./bulk_create_users_button";
|
import BulkCreateUsers from "./bulk_create_users_button";
|
||||||
import SSOSettings from "./SSOSettings";
|
import SSOSettings from "./SSOSettings";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
|
||||||
interface ViewUserDashboardProps {
|
interface ViewUserDashboardProps {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
@ -77,6 +80,16 @@ interface CreateuserProps {
|
||||||
onUserCreated: () => Promise<void>;
|
onUserCreated: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
email: string;
|
||||||
|
user_id: string;
|
||||||
|
user_role: string;
|
||||||
|
team: string;
|
||||||
|
model: string;
|
||||||
|
min_spend: number | null;
|
||||||
|
max_spend: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
@ -93,7 +106,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
setKeys,
|
setKeys,
|
||||||
}) => {
|
}) => {
|
||||||
const [userListResponse, setUserListResponse] = useState<UserListResponse | null>(null);
|
const [userListResponse, setUserListResponse] = useState<UserListResponse | null>(null);
|
||||||
const [userData, setUserData] = useState<null | any[]>(null);
|
|
||||||
const [endUsers, setEndUsers] = useState<null | any[]>(null);
|
const [endUsers, setEndUsers] = useState<null | any[]>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [openDialogId, setOpenDialogId] = React.useState<null | number>(null);
|
const [openDialogId, setOpenDialogId] = React.useState<null | number>(null);
|
||||||
|
@ -108,6 +120,19 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
const defaultPageSize = 25;
|
const defaultPageSize = 25;
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState("users");
|
const [activeTab, setActiveTab] = useState("users");
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
email: "",
|
||||||
|
user_id: "",
|
||||||
|
user_role: "",
|
||||||
|
team: "",
|
||||||
|
model: "",
|
||||||
|
min_spend: null,
|
||||||
|
max_spend: null
|
||||||
|
});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [showColumnDropdown, setShowColumnDropdown] = useState(false);
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState("Email");
|
||||||
|
const filtersRef = useRef(null);
|
||||||
|
|
||||||
// check if window is not undefined
|
// check if window is not undefined
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
@ -122,15 +147,66 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof FilterState, value: string | number | null) => {
|
||||||
|
const newFilters = { ...filters, [key]: value };
|
||||||
|
setFilters(newFilters);
|
||||||
|
debouncedSearch(newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a debounced version of the search function
|
||||||
|
const debouncedSearch = useCallback(
|
||||||
|
debounce(async (filters: FilterState) => {
|
||||||
|
if (!accessToken || !token || !userRole || !userID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Make the API call using userListCall with all filter parameters
|
||||||
|
const data = await userListCall(
|
||||||
|
accessToken,
|
||||||
|
filters.user_id ? [filters.user_id] : null,
|
||||||
|
1, // Reset to first page when searching
|
||||||
|
defaultPageSize,
|
||||||
|
filters.email || null,
|
||||||
|
filters.user_role || null,
|
||||||
|
filters.team || null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setUserListResponse(data);
|
||||||
|
console.log("called from debouncedSearch");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching users:", error);
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[accessToken, token, userRole, userID]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup the debounced function on component unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
if (value === "") {
|
||||||
|
refreshUserData(); // Reset to original data when search is cleared
|
||||||
|
} else {
|
||||||
|
debouncedSearch(filters);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (userToDelete && accessToken) {
|
if (userToDelete && accessToken) {
|
||||||
try {
|
try {
|
||||||
await userDeleteCall(accessToken, [userToDelete]);
|
await userDeleteCall(accessToken, [userToDelete]);
|
||||||
message.success("User deleted successfully");
|
message.success("User deleted successfully");
|
||||||
// Update the user list after deletion
|
// Update the user list after deletion
|
||||||
if (userData) {
|
if (userListResponse) {
|
||||||
const updatedUserData = userData.filter(user => user.user_id !== userToDelete);
|
const updatedUserData = userListResponse.users?.filter(user => user.user_id !== userToDelete);
|
||||||
setUserData(updatedUserData);
|
setUserListResponse({ ...userListResponse, users: updatedUserData || [] });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting user:", error);
|
console.error("Error deleting user:", error);
|
||||||
|
@ -164,11 +240,11 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("There was an error updating the user", error);
|
console.error("There was an error updating the user", error);
|
||||||
}
|
}
|
||||||
if (userData) {
|
if (userListResponse) {
|
||||||
const updatedUserData = userData.map((user) =>
|
const updatedUserData = userListResponse.users?.map((user) =>
|
||||||
user.user_id === editedUser.user_id ? editedUser : user
|
user.user_id === editedUser.user_id ? editedUser : user
|
||||||
);
|
);
|
||||||
setUserData(updatedUserData);
|
setUserListResponse({ ...userListResponse, users: updatedUserData || [] });
|
||||||
}
|
}
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
setEditModalVisible(false);
|
setEditModalVisible(false);
|
||||||
|
@ -195,14 +271,42 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
`userList_${currentPage}`,
|
`userList_${currentPage}`,
|
||||||
JSON.stringify(userDataResponse)
|
JSON.stringify(userDataResponse)
|
||||||
);
|
);
|
||||||
|
console.log("called from refreshUserData");
|
||||||
setUserListResponse(userDataResponse);
|
setUserListResponse(userDataResponse);
|
||||||
setUserData(userDataResponse.users || []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error refreshing user data:", error);
|
console.error("Error refreshing user data:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePageChange = async (newPage: number) => {
|
||||||
|
if (!accessToken || !token || !userRole || !userID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userDataResponse = await userListCall(
|
||||||
|
accessToken,
|
||||||
|
filters.user_id ? [filters.user_id] : null,
|
||||||
|
newPage,
|
||||||
|
defaultPageSize,
|
||||||
|
filters.email || null,
|
||||||
|
filters.user_role || null,
|
||||||
|
filters.team || null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update session storage with new data
|
||||||
|
sessionStorage.setItem(
|
||||||
|
`userList_${newPage}`,
|
||||||
|
JSON.stringify(userDataResponse)
|
||||||
|
);
|
||||||
|
|
||||||
|
setUserListResponse(userDataResponse);
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error changing page:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessToken || !token || !userRole || !userID) {
|
if (!accessToken || !token || !userRole || !userID) {
|
||||||
return;
|
return;
|
||||||
|
@ -214,16 +318,17 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
if (cachedUserData) {
|
if (cachedUserData) {
|
||||||
const parsedData = JSON.parse(cachedUserData);
|
const parsedData = JSON.parse(cachedUserData);
|
||||||
setUserListResponse(parsedData);
|
setUserListResponse(parsedData);
|
||||||
setUserData(parsedData.users || []);
|
console.log("called from useEffect");
|
||||||
} else {
|
} else {
|
||||||
// Fetch from API if not in cache
|
// Fetch from API using userListCall with current filters
|
||||||
const userDataResponse = await userInfoCall(
|
const userDataResponse = await userListCall(
|
||||||
accessToken,
|
accessToken,
|
||||||
null,
|
filters.user_id ? [filters.user_id] : null,
|
||||||
userRole,
|
|
||||||
true,
|
|
||||||
currentPage,
|
currentPage,
|
||||||
defaultPageSize
|
defaultPageSize,
|
||||||
|
filters.email || null,
|
||||||
|
filters.user_role || null,
|
||||||
|
filters.team || null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store in session storage
|
// Store in session storage
|
||||||
|
@ -233,7 +338,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
setUserListResponse(userDataResponse);
|
setUserListResponse(userDataResponse);
|
||||||
setUserData(userDataResponse.users || []);
|
console.log("called from useEffect 2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch roles if not cached
|
// Fetch roles if not cached
|
||||||
|
@ -254,9 +359,9 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
fetchData();
|
fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [accessToken, token, userRole, userID, currentPage]);
|
}, [accessToken, token, userRole, userID]);
|
||||||
|
|
||||||
if (!userData) {
|
if (!userListResponse) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -297,8 +402,150 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="border-b px-6 py-4">
|
<div className="border-b px-6 py-4">
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
|
<div className="flex flex-col space-y-4">
|
||||||
<div className="flex items-center space-x-4">
|
{/* Search and Filter Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Email Search */}
|
||||||
|
<div className="relative w-64">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by email..."
|
||||||
|
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
value={filters.email}
|
||||||
|
onChange={(e) => handleFilterChange('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Button */}
|
||||||
|
<button
|
||||||
|
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? 'bg-gray-100' : ''}`}
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Filters
|
||||||
|
{(filters.user_id || filters.user_role || filters.team) && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reset Filters Button */}
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setFilters({
|
||||||
|
email: "",
|
||||||
|
user_id: "",
|
||||||
|
user_role: "",
|
||||||
|
team: "",
|
||||||
|
model: "",
|
||||||
|
min_spend: null,
|
||||||
|
max_spend: null
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Filters */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||||
|
{/* User ID Search */}
|
||||||
|
<div className="relative w-64">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by User ID"
|
||||||
|
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
value={filters.user_id}
|
||||||
|
onChange={(e) => handleFilterChange('user_id', e.target.value)}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Dropdown */}
|
||||||
|
<div className="w-64">
|
||||||
|
<Select
|
||||||
|
value={filters.user_role}
|
||||||
|
onValueChange={(value) => handleFilterChange('user_role', value)}
|
||||||
|
placeholder="Select Role"
|
||||||
|
>
|
||||||
|
{Object.entries(possibleUIRoles).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value.ui_label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Dropdown */}
|
||||||
|
<div className="w-64">
|
||||||
|
<Select
|
||||||
|
value={filters.team}
|
||||||
|
onValueChange={(value) => handleFilterChange('team', value)}
|
||||||
|
placeholder="Select Team"
|
||||||
|
>
|
||||||
|
{teams?.map((team) => (
|
||||||
|
<SelectItem key={team.team_id} value={team.team_id}>
|
||||||
|
{team.team_alias || team.team_id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Count and Pagination */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
Showing{" "}
|
Showing{" "}
|
||||||
{userListResponse && userListResponse.users && userListResponse.users.length > 0
|
{userListResponse && userListResponse.users && userListResponse.users.length > 0
|
||||||
|
@ -313,25 +560,28 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
: 0}{" "}
|
: 0}{" "}
|
||||||
of {userListResponse ? userListResponse.total : 0} results
|
of {userListResponse ? userListResponse.total : 0} results
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
|
{/* Pagination Buttons */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
disabled={!userListResponse || currentPage <= 1}
|
disabled={currentPage === 1}
|
||||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
className={`px-3 py-1 text-sm border rounded-md ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
Page {userListResponse ? userListResponse.page : "-"} of{" "}
|
|
||||||
{userListResponse ? userListResponse.total_pages : "-"}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => p + 1)}
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
disabled={
|
disabled={!userListResponse || currentPage >= userListResponse.total_pages}
|
||||||
!userListResponse ||
|
className={`px-3 py-1 text-sm border rounded-md ${
|
||||||
currentPage >= userListResponse.total_pages
|
!userListResponse || currentPage >= userListResponse.total_pages
|
||||||
}
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
@ -339,10 +589,11 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserDataTable
|
<UserDataTable
|
||||||
data={userData || []}
|
data={userListResponse.users || []}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
isLoading={!userData}
|
isLoading={!userListResponse}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue