From 66680c421d087a569fea8dbc0d46ceae2044cdaf Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 22 Apr 2025 13:59:43 -0700 Subject: [PATCH] Add global filtering to Users tab (#10195) * style(internal_user_endpoints.py): add response model to `/user/list` endpoint make sure we maintain consistent response spec * fix(key_management_endpoints.py): return 'created_at' and 'updated_at' on `/key/generate` Show 'created_at' on UI when key created * test(test_keys.py): add e2e test to ensure created at is always returned * fix(view_users.tsx): support global search by user email allows easier search * test(search_users.spec.ts): add e2e test ensure user search works on admin ui * fix(view_users.tsx): support filtering user by role and user id More powerful filtering on internal users table * fix(view_users.tsx): allow filtering users by team * style(view_users.tsx): cleanup ui to show filters in consistent style * refactor(view_users.tsx): cleanup to just use 1 variable for the data * fix(view_users.tsx): cleanup use effect hooks * fix(internal_user_endpoints.py): fix check to pass testing * test: update tests * test: update tests * Revert "test: update tests" This reverts commit 6553eeb2325f77e302ff67d6c1fc736e67f854bb. * fix(view_userts.tsx): add back in 'previous' and 'next' tabs for pagination --- .../proxy/_experimental/out/onboarding.html | 1 - litellm/proxy/_types.py | 2 + .../internal_user_endpoints.py | 62 ++-- .../key_management_endpoints.py | 3 + .../internal_user_endpoints.py | 18 + test-results/.last-run.json | 4 + .../e2e_ui_tests/search_users.spec.ts | 119 +++++++ .../e2e_ui_tests/view_internal_user.spec.ts | 73 ++-- .../test-results/.last-run.json | 8 + tests/test_keys.py | 12 + .../src/components/networking.tsx | 15 + .../src/components/view_users.tsx | 327 ++++++++++++++++-- 12 files changed, 556 insertions(+), 88 deletions(-) delete mode 100644 litellm/proxy/_experimental/out/onboarding.html create mode 100644 litellm/types/proxy/management_endpoints/internal_user_endpoints.py create mode 100644 test-results/.last-run.json create mode 100644 tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts create mode 100644 tests/proxy_admin_ui_tests/test-results/.last-run.json diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index bf33fa19ec..0000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 150528fd8f..354f6bb54c 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -687,6 +687,8 @@ class GenerateKeyResponse(KeyRequestBase): token: Optional[str] = None created_by: Optional[str] = None updated_by: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None @model_validator(mode="before") @classmethod diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 42a5dd7e81..0d6c9ee622 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -43,6 +43,9 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, SpendMetrics, ) +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + UserListResponse, +) router = APIRouter() @@ -899,15 +902,11 @@ async def get_user_key_counts( return result -@router.get( - "/user/get_users", - tags=["Internal User management"], - dependencies=[Depends(user_api_key_auth)], -) @router.get( "/user/list", tags=["Internal User management"], dependencies=[Depends(user_api_key_auth)], + response_model=UserListResponse, ) async def get_users( role: Optional[str] = fastapi.Query( @@ -916,15 +915,19 @@ async def get_users( user_ids: Optional[str] = fastapi.Query( 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_size: int = fastapi.Query( default=25, ge=1, le=100, description="Number of items per page" ), ): """ - Get a paginated list of users, optionally filtered by role. - - Used by the UI to populate the user lists. + Get a paginated list of users with filtering options. Parameters: role: Optional[str] @@ -935,17 +938,17 @@ async def get_users( - internal_user_viewer user_ids: Optional[str] 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 The page number to return page_size: int The number of items per page - Currently - admin-only endpoint. - - Example curl: - ``` - http://0.0.0.0:4000/user/list?user_ids=default_user_id,693c1a4a-1cc0-4c7c-afe8-b5d2c8d52e17 - ``` + Returns: + UserListResponse with filtered and paginated users """ from litellm.proxy.proxy_server import prisma_client @@ -958,25 +961,32 @@ async def get_users( # Calculate skip and take for pagination skip = (page - 1) * page_size - # Prepare the query conditions # Build where conditions based on provided parameters where_conditions: Dict[str, Any] = {} if role: - where_conditions["user_role"] = { - "contains": role, - "mode": "insensitive", # Case-insensitive search - } + where_conditions["user_role"] = role # Exact match instead of contains if user_ids and isinstance(user_ids, str): user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()] where_conditions["user_id"] = { - "in": user_id_list, # Now passing a list of strings as required by Prisma + "in": user_id_list, } - users: Optional[ - List[LiteLLM_UserTable] - ] = await prisma_client.db.litellm_usertable.find_many( + if user_email is not None and isinstance(user_email, str): + where_conditions["user_email"] = { + "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, skip=skip, take=page_size, @@ -984,9 +994,7 @@ async def get_users( ) # Get total count of user rows - total_count = await prisma_client.db.litellm_usertable.count( - where=where_conditions # type: ignore - ) + total_count = await prisma_client.db.litellm_usertable.count(where=where_conditions) # Get key count for each user if users is not None: @@ -1009,7 +1017,7 @@ async def get_users( LiteLLM_UserTableWithKeyCount( **user.model_dump(), key_count=user_key_counts.get(user.user_id, 0) ) - ) # Return full key object + ) else: user_list = [] diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 8fd3b555d4..15edab8909 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1347,10 +1347,13 @@ async def generate_key_helper_fn( # noqa: PLR0915 create_key_response = await prisma_client.insert_data( data=key_data, table_name="key" ) + key_data["token_id"] = getattr(create_key_response, "token", None) key_data["litellm_budget_table"] = getattr( 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: verbose_proxy_logger.error( "litellm.proxy.proxy_server.generate_key_helper_fn(): Exception occured - {}".format( diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py new file mode 100644 index 0000000000..5c2c5bf371 --- /dev/null +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -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 diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000000..5fca3f84bc --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts new file mode 100644 index 0000000000..ef5dfa6c0c --- /dev/null +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts @@ -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); +}); diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts index 4d27a4a7ce..bb6df91c39 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts @@ -2,45 +2,74 @@ 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 - await page.goto('http://localhost:4000/ui'); + await page.goto("http://localhost:4000/ui"); // 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 - 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"]'); await expect(loginButton).toBeEnabled(); - - // Optionally, you can click the login button to submit the form 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(); - // try to click on button - // - // wait 1-2 seconds - await page.waitForTimeout(10000); + // Wait for the table to load + await page.waitForSelector("tbody tr", { timeout: 10000 }); + await page.waitForTimeout(2000); // Additional wait for table to stabilize - // Test all expected fields are present - // number of keys owned by user - const keysBadges = page.locator('p.tremor-Badge-text.text-sm.whitespace-nowrap', { hasText: 'Keys' }); - const keysCountArray = await keysBadges.evaluateAll(elements => elements.map(el => parseInt(el.textContent.split(' ')[0], 10))); + // Test all expected fields are present + // number of keys owned by user + const keysBadges = page.locator( + "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); // 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' }); - await expect(prevButton).toBeDisabled(); + // Wait for pagination controls to be visible + 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' }); - await expect(nextButton).toBeEnabled(); + // Check if we're on the first page by looking at the results count + 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(); + } }); diff --git a/tests/proxy_admin_ui_tests/test-results/.last-run.json b/tests/proxy_admin_ui_tests/test-results/.last-run.json new file mode 100644 index 0000000000..0c96e15dce --- /dev/null +++ b/tests/proxy_admin_ui_tests/test-results/.last-run.json @@ -0,0 +1,8 @@ +{ + "status": "failed", + "failedTests": [ + "8306bf902634636ae770-183086b993a71bc98dd6", + "1bfc70f64c2dd4741dbb-58cd256736ebe53a2d97", + "ea1c46def20befad7a54-cb6c473c41474485b610" + ] +} \ No newline at end of file diff --git a/tests/test_keys.py b/tests/test_keys.py index eaf9369d89..89b54ba92c 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -109,6 +109,18 @@ async def test_key_gen(): 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 async def test_key_gen_bad_key(): """ diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f16aaf30fa..a1fdf7d073 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -676,6 +676,9 @@ export const userListCall = async ( userIDs: string[] | null = null, page: 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 @@ -698,6 +701,18 @@ export const userListCall = async ( if (page_size) { 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(); if (queryString) { diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 6052269586..99ae2c979e 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Card, Title, @@ -23,6 +23,7 @@ import { DialogPanel, Icon, TextInput, + NumberInput, } from "@tremor/react"; import { message } from "antd"; @@ -32,6 +33,7 @@ import { userInfoCall, userUpdateUserCall, getPossibleUserRoles, + userListCall, } from "./networking"; import { Badge, BadgeDelta, Button } from "@tremor/react"; import RequestAccess from "./request_model_access"; @@ -50,6 +52,7 @@ import { UserDataTable } from "./view_users/table"; import { UserInfo } from "./view_users/types"; import BulkCreateUsers from "./bulk_create_users_button"; import SSOSettings from "./SSOSettings"; +import debounce from "lodash/debounce"; interface ViewUserDashboardProps { accessToken: string | null; @@ -77,6 +80,16 @@ interface CreateuserProps { onUserCreated: () => Promise; } +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 proxyBaseUrl = isLocal ? "http://localhost:4000" : null; if (isLocal != true) { @@ -93,7 +106,6 @@ const ViewUserDashboard: React.FC = ({ setKeys, }) => { const [userListResponse, setUserListResponse] = useState(null); - const [userData, setUserData] = useState(null); const [endUsers, setEndUsers] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [openDialogId, setOpenDialogId] = React.useState(null); @@ -108,6 +120,19 @@ const ViewUserDashboard: React.FC = ({ const defaultPageSize = 25; const [searchTerm, setSearchTerm] = useState(""); const [activeTab, setActiveTab] = useState("users"); + const [filters, setFilters] = useState({ + 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 if (typeof window !== "undefined") { @@ -122,15 +147,66 @@ const ViewUserDashboard: React.FC = ({ 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 () => { if (userToDelete && accessToken) { try { await userDeleteCall(accessToken, [userToDelete]); message.success("User deleted successfully"); // Update the user list after deletion - if (userData) { - const updatedUserData = userData.filter(user => user.user_id !== userToDelete); - setUserData(updatedUserData); + if (userListResponse) { + const updatedUserData = userListResponse.users?.filter(user => user.user_id !== userToDelete); + setUserListResponse({ ...userListResponse, users: updatedUserData || [] }); } } catch (error) { console.error("Error deleting user:", error); @@ -164,11 +240,11 @@ const ViewUserDashboard: React.FC = ({ } catch (error) { console.error("There was an error updating the user", error); } - if (userData) { - const updatedUserData = userData.map((user) => + if (userListResponse) { + const updatedUserData = userListResponse.users?.map((user) => user.user_id === editedUser.user_id ? editedUser : user ); - setUserData(updatedUserData); + setUserListResponse({ ...userListResponse, users: updatedUserData || [] }); } setSelectedUser(null); setEditModalVisible(false); @@ -195,14 +271,42 @@ const ViewUserDashboard: React.FC = ({ `userList_${currentPage}`, JSON.stringify(userDataResponse) ); - + console.log("called from refreshUserData"); setUserListResponse(userDataResponse); - setUserData(userDataResponse.users || []); } catch (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(() => { if (!accessToken || !token || !userRole || !userID) { return; @@ -214,16 +318,17 @@ const ViewUserDashboard: React.FC = ({ if (cachedUserData) { const parsedData = JSON.parse(cachedUserData); setUserListResponse(parsedData); - setUserData(parsedData.users || []); + console.log("called from useEffect"); } else { - // Fetch from API if not in cache - const userDataResponse = await userInfoCall( + // Fetch from API using userListCall with current filters + const userDataResponse = await userListCall( accessToken, - null, - userRole, - true, + filters.user_id ? [filters.user_id] : null, currentPage, - defaultPageSize + defaultPageSize, + filters.email || null, + filters.user_role || null, + filters.team || null ); // Store in session storage @@ -233,7 +338,7 @@ const ViewUserDashboard: React.FC = ({ ); setUserListResponse(userDataResponse); - setUserData(userDataResponse.users || []); + console.log("called from useEffect 2"); } // Fetch roles if not cached @@ -254,9 +359,9 @@ const ViewUserDashboard: React.FC = ({ fetchData(); } - }, [accessToken, token, userRole, userID, currentPage]); + }, [accessToken, token, userRole, userID]); - if (!userData) { + if (!userListResponse) { return
Loading...
; } @@ -297,8 +402,150 @@ const ViewUserDashboard: React.FC = ({
-
-
+
+ {/* Search and Filter Controls */} +
+ {/* Email Search */} +
+ handleFilterChange('email', e.target.value)} + /> + + + +
+ + {/* Filter Button */} + + + {/* Reset Filters Button */} + +
+ + {/* Additional Filters */} + {showFilters && ( +
+ {/* User ID Search */} +
+ handleFilterChange('user_id', e.target.value)} + /> + + + +
+ + {/* Role Dropdown */} +
+ +
+ + {/* Team Dropdown */} +
+ +
+
+ )} + + {/* Results Count and Pagination */} +
Showing{" "} {userListResponse && userListResponse.users && userListResponse.users.length > 0 @@ -313,25 +560,28 @@ const ViewUserDashboard: React.FC = ({ : 0}{" "} of {userListResponse ? userListResponse.total : 0} results -
+ + {/* Pagination Buttons */} +
- - Page {userListResponse ? userListResponse.page : "-"} of{" "} - {userListResponse ? userListResponse.total_pages : "-"} - @@ -339,10 +589,11 @@ const ViewUserDashboard: React.FC = ({
+