Spaces:
Running
Running
import React, { useState, useEffect, useCallback, useRef } from "react"; | |
import { | |
Tab, | |
TabGroup, | |
TabList, | |
TabPanels, | |
TabPanel, | |
Select, | |
SelectItem, | |
} from "@tremor/react"; | |
import { message } from "antd"; | |
import { | |
userInfoCall, | |
userUpdateUserCall, | |
getPossibleUserRoles, | |
userListCall, | |
UserListResponse, | |
} from "./networking"; | |
import { Button } from "@tremor/react"; | |
import CreateUser from "./create_user_button"; | |
import EditUserModal from "./edit_user"; | |
import { userDeleteCall } from "./networking"; | |
import { columns } from "./view_users/columns"; | |
import { UserDataTable } from "./view_users/table"; | |
import { UserInfo } from "./view_users/types"; | |
import SSOSettings from "./SSOSettings"; | |
import debounce from "lodash/debounce"; | |
import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
import { updateExistingKeys } from "@/utils/dataUtils"; | |
import { useDebouncedState } from '@tanstack/react-pacer/debouncer' | |
interface ViewUserDashboardProps { | |
accessToken: string | null; | |
token: string | null; | |
keys: any[] | null; | |
userRole: string | null; | |
userID: string | null; | |
teams: any[] | null; | |
setKeys: React.Dispatch<React.SetStateAction<Object[] | null>>; | |
} | |
interface FilterState { | |
email: string; | |
user_id: string; | |
user_role: string; | |
sso_user_id: string; | |
team: string; | |
model: string; | |
min_spend: number | null; | |
max_spend: number | null; | |
sort_by: string; | |
sort_order: 'asc' | 'desc'; | |
} | |
const isLocal = process.env.NODE_ENV === "development"; | |
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; | |
if (isLocal != true) { | |
console.log = function() {}; | |
} | |
const DEFAULT_PAGE_SIZE = 25; | |
const initialFilters: FilterState = { | |
email: "", | |
user_id: "", | |
user_role: "", | |
sso_user_id: "", | |
team: "", | |
model: "", | |
min_spend: null, | |
max_spend: null, | |
sort_by: "created_at", | |
sort_order: "desc" | |
} | |
const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ | |
accessToken, | |
token, | |
userRole, | |
userID, | |
teams, | |
}) => { | |
const queryClient = useQueryClient(); | |
const [currentPage, setCurrentPage] = useState(1); | |
const [editModalVisible, setEditModalVisible] = useState(false); | |
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null); | |
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | |
const [userToDelete, setUserToDelete] = useState<string | null>(null); | |
const [activeTab, setActiveTab] = useState("users"); | |
const [filters, setFilters] = useState<FilterState>(initialFilters); | |
const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: 300 }) | |
const [showFilters, setShowFilters] = useState(false); | |
const handleDelete = (userId: string) => { | |
setUserToDelete(userId); | |
setIsDeleteModalOpen(true); | |
}; | |
useEffect(() => { | |
return () => { | |
debouncer.cancel() | |
} | |
}, [debouncer]) | |
const updateFilters = (update: Partial<FilterState>) => { | |
setFilters((previousFilters) => { | |
const newFilters = {...previousFilters, ...update }; | |
setDebouncedFilters(newFilters); | |
return newFilters; | |
}) | |
}; | |
const handleSortChange = (sortBy: string, sortOrder: 'asc' | 'desc') => { | |
updateFilters({ sort_by: sortBy, sort_order: sortOrder }); | |
}; | |
const confirmDelete = async () => { | |
if (userToDelete && accessToken) { | |
try { | |
await userDeleteCall(accessToken, [userToDelete]); | |
// Update the user list after deletion | |
queryClient.setQueriesData<UserListResponse>({ queryKey: ['userList'] }, (previousData) => { | |
if (previousData === undefined) return previousData; | |
const updatedUsers = previousData.users.filter(user => user.user_id !== userToDelete); | |
return { ...previousData, users: updatedUsers }; | |
}) | |
message.success("User deleted successfully"); | |
} catch (error) { | |
console.error("Error deleting user:", error); | |
message.error("Failed to delete user"); | |
} | |
} | |
setIsDeleteModalOpen(false); | |
setUserToDelete(null); | |
}; | |
const cancelDelete = () => { | |
setIsDeleteModalOpen(false); | |
setUserToDelete(null); | |
}; | |
const handleEditCancel = async () => { | |
setSelectedUser(null); | |
setEditModalVisible(false); | |
}; | |
const handleEditSubmit = async (editedUser: any) => { | |
console.log("inside handleEditSubmit:", editedUser); | |
if (!accessToken || !token || !userRole || !userID) { | |
return; | |
} | |
try { | |
const response = await userUpdateUserCall(accessToken, editedUser, null); | |
queryClient.setQueriesData<UserListResponse>({ queryKey: ['userList'] }, (previousData) => { | |
if (previousData === undefined) return previousData; | |
const updatedUsers = previousData.users.map(user => { | |
if (user.user_id === response.data.user_id) { | |
return updateExistingKeys(user, response.data); | |
} | |
return user; | |
}); | |
return { ...previousData, users: updatedUsers }; | |
}) | |
message.success(`User ${editedUser.user_id} updated successfully`); | |
} catch (error) { | |
console.error("There was an error updating the user", error); | |
} | |
setSelectedUser(null); | |
setEditModalVisible(false); | |
// Close the modal | |
}; | |
const handlePageChange = async (newPage: number) => { | |
setCurrentPage(newPage); | |
}; | |
const userListQuery = useQuery({ | |
queryKey: ['userList', { debouncedFilter: debouncedFilters, currentPage }], | |
queryFn: async () => { | |
if (!accessToken) throw new Error('Access token required'); | |
return await userListCall( | |
accessToken, | |
debouncedFilters.user_id ? [debouncedFilters.user_id] : null, | |
currentPage, | |
DEFAULT_PAGE_SIZE, | |
debouncedFilters.email || null, | |
debouncedFilters.user_role || null, | |
debouncedFilters.team || null, | |
debouncedFilters.sso_user_id || null, | |
debouncedFilters.sort_by, | |
debouncedFilters.sort_order | |
); | |
}, | |
enabled: Boolean(accessToken && token && userRole && userID), | |
placeholderData: (previousData) => previousData | |
}); | |
const userListResponse = userListQuery.data | |
const userRolesQuery = useQuery<Record<string, Record<string, string>>>({ | |
queryKey: ['userRoles'], | |
initialData: () => ({}), | |
queryFn: async () => { | |
if (!accessToken) throw new Error('Access token required'); | |
return await getPossibleUserRoles(accessToken); | |
}, | |
enabled: Boolean(accessToken && token && userRole && userID), | |
}); | |
const possibleUIRoles = userRolesQuery.data | |
if (userListQuery.isLoading) { | |
return <div>Loading...</div> | |
} | |
if (!accessToken || !token || !userRole || !userID) { | |
return <div>Loading...</div>; | |
} | |
const tableColumns = columns( | |
possibleUIRoles, | |
(user) => { | |
setSelectedUser(user); | |
setEditModalVisible(true); | |
}, | |
handleDelete | |
); | |
return ( | |
<div className="w-full p-6"> | |
<div className="flex items-center justify-between mb-4"> | |
<h1 className="text-xl font-semibold">Users</h1> | |
<div className="flex space-x-3"> | |
<CreateUser | |
userID={userID} | |
accessToken={accessToken} | |
teams={teams} | |
possibleUIRoles={possibleUIRoles} | |
/> | |
</div> | |
</div> | |
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}> | |
<TabList className="mb-4"> | |
<Tab>Users</Tab> | |
<Tab>Default User Settings</Tab> | |
</TabList> | |
<TabPanels> | |
<TabPanel> | |
<div className="bg-white rounded-lg shadow"> | |
<div className="border-b px-6 py-4"> | |
<div className="flex flex-col space-y-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) => updateFilters({ 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={() => { | |
updateFilters(initialFilters); | |
}} | |
> | |
<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) => updateFilters({ 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) => updateFilters({ 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) => updateFilters({ 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> | |
{/* SSO ID Search */} | |
<div className="relative w-64"> | |
<input | |
type="text" | |
placeholder="Filter by SSO 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.sso_user_id} | |
onChange={(e) => updateFilters({ sso_user_id : e.target.value })} | |
/> | |
</div> | |
</div> | |
)} | |
{/* Results Count and Pagination */} | |
<div className="flex justify-between items-center"> | |
<span className="text-sm text-gray-700"> | |
Showing{" "} | |
{userListResponse && userListResponse.users && userListResponse.users.length > 0 | |
? (userListResponse.page - 1) * userListResponse.page_size + 1 | |
: 0}{" "} | |
-{" "} | |
{userListResponse && userListResponse.users | |
? Math.min( | |
userListResponse.page * userListResponse.page_size, | |
userListResponse.total | |
) | |
: 0}{" "} | |
of {userListResponse ? userListResponse.total : 0} results | |
</span> | |
{/* Pagination Buttons */} | |
<div className="flex space-x-2"> | |
<button | |
onClick={() => handlePageChange(currentPage - 1)} | |
disabled={currentPage === 1} | |
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 | |
</button> | |
<button | |
onClick={() => handlePageChange(currentPage + 1)} | |
disabled={!userListResponse || currentPage >= userListResponse.total_pages} | |
className={`px-3 py-1 text-sm border rounded-md ${ | |
!userListResponse || currentPage >= userListResponse.total_pages | |
? 'bg-gray-100 text-gray-400 cursor-not-allowed' | |
: 'hover:bg-gray-50' | |
}`} | |
> | |
Next | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
<UserDataTable | |
data={userListQuery.data?.users || []} | |
columns={tableColumns} | |
isLoading={userListQuery.isLoading} | |
accessToken={accessToken} | |
userRole={userRole} | |
onSortChange={handleSortChange} | |
currentSort={{ | |
sortBy: filters.sort_by, | |
sortOrder: filters.sort_order | |
}} | |
possibleUIRoles={possibleUIRoles} | |
/> | |
</div> | |
</TabPanel> | |
<TabPanel> | |
<SSOSettings accessToken={accessToken} possibleUIRoles={possibleUIRoles} userID={userID} userRole={userRole}/> | |
</TabPanel> | |
</TabPanels> | |
</TabGroup> | |
{/* 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="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> | |
<div | |
className="fixed inset-0 transition-opacity" | |
aria-hidden="true" | |
> | |
<div className="absolute inset-0 bg-gray-500 opacity-75"></div> | |
</div> | |
{/* Modal Panel */} | |
<span | |
className="hidden sm:inline-block sm:align-middle sm:h-screen" | |
aria-hidden="true" | |
> | |
​ | |
</span> | |
{/* Confirmation Modal Content */} | |
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> | |
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> | |
<div className="sm:flex sm:items-start"> | |
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> | |
<h3 className="text-lg leading-6 font-medium text-gray-900"> | |
Delete User | |
</h3> | |
<div className="mt-2"> | |
<p className="text-sm text-gray-500"> | |
Are you sure you want to delete this user? | |
</p> | |
<p className="text-sm font-medium text-gray-900 mt-2"> | |
User ID: {userToDelete} | |
</p> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> | |
<Button onClick={confirmDelete} color="red" className="ml-2"> | |
Delete | |
</Button> | |
<Button onClick={cancelDelete}>Cancel</Button> | |
</div> | |
</div> | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
}; | |
export default ViewUserDashboard; | |