user tab enhanced and user search bug fixed

This commit is contained in:
laxmanhalaki 2025-11-12 18:24:19 +05:30
parent f022cbf899
commit 891096a184
5 changed files with 290 additions and 91 deletions

View File

@ -67,13 +67,21 @@ export function UserRoleManager() {
const [updating, setUpdating] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Users with elevated roles
const [elevatedUsers, setElevatedUsers] = useState<UserWithRole[]>([]);
// Users list with filtering and pagination
const [users, setUsers] = useState<UserWithRole[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0 });
// Ref for search container (click outside to close)
// Pagination and filtering
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
const limit = 10;
// Refs for search container and user list
const searchContainerRef = useRef<HTMLDivElement>(null);
const userListRef = useRef<HTMLDivElement>(null);
// Search users from Okta
const searchUsers = useCallback(
@ -134,7 +142,7 @@ export function UserRoleManager() {
try {
// Call backend to assign role (will create user if doesn't exist)
const response = await userApi.assignRole(selectedUser.email, selectedRole);
await userApi.assignRole(selectedUser.email, selectedRole);
setMessage({
type: 'success',
@ -146,8 +154,8 @@ export function UserRoleManager() {
setSearchQuery('');
setSelectedRole('USER');
// Refresh the elevated users list
await fetchElevatedUsers();
// Refresh the users list
await fetchUsers();
await fetchRoleStatistics();
} catch (error: any) {
console.error('Role assignment failed:', error);
@ -160,26 +168,39 @@ export function UserRoleManager() {
}
};
// Fetch users with ADMIN and MANAGEMENT roles
const fetchElevatedUsers = async () => {
// Fetch users with filtering and pagination
const fetchUsers = async (page: number = currentPage) => {
setLoadingUsers(true);
try {
const [adminResponse, managementResponse] = await Promise.all([
userApi.getUsersByRole('ADMIN'),
userApi.getUsersByRole('MANAGEMENT')
]);
const response = await userApi.getUsersByRole(roleFilter, page, limit);
console.log('Admin response:', adminResponse);
console.log('Management response:', managementResponse);
console.log('Users response:', response);
// Backend returns { success: true, data: { users: [...], summary: {...} } }
const admins = adminResponse.data?.data?.users || [];
const managers = managementResponse.data?.data?.users || [];
// Backend returns { success: true, data: { users: [...], pagination: {...}, summary: {...} } }
const usersData = response.data?.data?.users || [];
const paginationData = response.data?.data?.pagination;
const summaryData = response.data?.data?.summary;
console.log('Parsed admins:', admins);
console.log('Parsed managers:', managers);
console.log('Parsed users:', usersData);
console.log('Pagination:', paginationData);
console.log('Summary:', summaryData);
setElevatedUsers([...admins, ...managers]);
setUsers(usersData);
if (paginationData) {
setCurrentPage(paginationData.currentPage);
setTotalPages(paginationData.totalPages);
setTotalUsers(paginationData.totalUsers);
}
// Update summary stats if available
if (summaryData) {
setRoleStats({
admins: summaryData.ADMIN || 0,
management: summaryData.MANAGEMENT || 0,
users: summaryData.USER || 0
});
}
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
@ -207,11 +228,39 @@ export function UserRoleManager() {
}
};
// Load data on mount
// Load data on mount and when filter changes
useEffect(() => {
fetchElevatedUsers();
fetchUsers(1); // Reset to page 1 when filter changes
fetchRoleStatistics();
}, []);
}, [roleFilter]);
// Handle filter change
const handleFilterChange = (value: string) => {
setRoleFilter(value as any);
setCurrentPage(1);
};
// Handle page change
const handlePageChange = (page: number) => {
fetchUsers(page);
};
// Handle statistics card click - filter and scroll to user list
const handleStatCardClick = (filter: 'ADMIN' | 'MANAGEMENT' | 'USER') => {
setRoleFilter(filter);
setCurrentPage(1);
// Immediate scroll without waiting for data load
requestAnimationFrame(() => {
const element = userListRef.current;
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
};
// Handle click outside to close search results
useEffect(() => {
@ -256,13 +305,21 @@ export function UserRoleManager() {
<div className="space-y-6">
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
<Card className="shadow-lg border-0 bg-gradient-to-br from-yellow-50 to-yellow-100/50 hover:shadow-xl transition-all rounded-xl" data-testid="admin-count-card">
<Card
className={`border-2 bg-gradient-to-br from-yellow-50 to-yellow-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
roleFilter === 'ADMIN' ? 'border-yellow-400 shadow-lg' : 'border-transparent shadow-md'
}`}
data-testid="admin-count-card"
onClick={() => handleStatCardClick('ADMIN')}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Administrators</p>
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="admin-count">{roleStats.admins}</p>
<p className="text-xs text-gray-500 mt-1">Full system access</p>
<p className="text-xs text-yellow-700 mt-1 font-semibold">
{roleFilter === 'ADMIN' ? '✓ Viewing' : 'Click to view'}
</p>
</div>
<div className="p-3 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-xl shadow-md">
<Crown className="w-6 h-6 text-slate-900" />
@ -271,13 +328,21 @@ export function UserRoleManager() {
</CardContent>
</Card>
<Card className="shadow-lg border-0 bg-gradient-to-br from-blue-50 to-blue-100/50 hover:shadow-xl transition-all rounded-xl" data-testid="management-count-card">
<Card
className={`border-2 bg-gradient-to-br from-blue-50 to-blue-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
roleFilter === 'MANAGEMENT' ? 'border-blue-400 shadow-lg' : 'border-transparent shadow-md'
}`}
data-testid="management-count-card"
onClick={() => handleStatCardClick('MANAGEMENT')}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Management</p>
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="management-count">{roleStats.management}</p>
<p className="text-xs text-gray-500 mt-1">Read all data access</p>
<p className="text-xs text-blue-700 mt-1 font-semibold">
{roleFilter === 'MANAGEMENT' ? '✓ Viewing' : 'Click to view'}
</p>
</div>
<div className="p-3 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl shadow-md">
<Users className="w-6 h-6 text-slate-900" />
@ -286,13 +351,21 @@ export function UserRoleManager() {
</CardContent>
</Card>
<Card className="shadow-lg border-0 bg-gradient-to-br from-gray-50 to-gray-100/50 hover:shadow-xl transition-all rounded-xl" data-testid="user-count-card">
<Card
className={`border-2 bg-gradient-to-br from-gray-50 to-gray-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
roleFilter === 'USER' ? 'border-gray-400 shadow-lg' : 'border-transparent shadow-md'
}`}
data-testid="user-count-card"
onClick={() => handleStatCardClick('USER')}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Regular Users</p>
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="user-count">{roleStats.users}</p>
<p className="text-xs text-gray-500 mt-1">Standard access</p>
<p className="text-xs text-gray-700 mt-1 font-semibold">
{roleFilter === 'USER' ? '✓ Viewing' : 'Click to view'}
</p>
</div>
<div className="p-3 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl shadow-md">
<UserIcon className="w-6 h-6 text-white" />
@ -328,7 +401,7 @@ export function UserRoleManager() {
placeholder="Type name or email address..."
value={searchQuery}
onChange={handleSearchChange}
className="pl-10 pr-10 h-12 border-2 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
className="pl-10 pr-10 h-12 border rounded-lg border-gray-300 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
data-testid="user-search-input"
/>
{searching && (
@ -339,13 +412,13 @@ export function UserRoleManager() {
{/* Search Results Dropdown */}
{searchResults.length > 0 && (
<div className="border-2 border-purple-200 rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
<div className="border border-purple-200 rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
<div className="sticky top-0 bg-purple-50 px-4 py-2 border-b border-purple-100">
<p className="text-xs font-semibold text-purple-700">
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
</p>
</div>
<div className="p-2">
<div className="p-3">
{searchResults.map((user) => (
<button
key={user.userId}
@ -407,25 +480,25 @@ export function UserRoleManager() {
<label className="text-sm font-medium text-gray-700">Select Role</label>
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
<SelectTrigger
className="h-12 border-2 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
className="h-12 border border-gray-300 py-2 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200 transition-all"
data-testid="role-select"
>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">
<SelectContent className="rounded-lg">
<SelectItem value="USER" className="p-3 rounded-lg my-1">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-600" />
<span>User - Regular access</span>
</div>
</SelectItem>
<SelectItem value="MANAGEMENT">
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600" />
<span>Management - Read all data</span>
</div>
</SelectItem>
<SelectItem value="ADMIN">
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-yellow-600" />
<span>Administrator - Full access</span>
@ -477,24 +550,61 @@ export function UserRoleManager() {
</CardContent>
</Card>
{/* Elevated Users List */}
<Card className="shadow-lg border">
<CardHeader className="border-b pb-4">
<div className="flex items-center justify-between">
{/* Users List with Filter and Pagination */}
<div ref={userListRef}>
<Card className="shadow-lg border">
<CardHeader className="border-b pb-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-lg shadow-md">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold">Users with Elevated Roles</CardTitle>
<CardTitle className="text-lg font-semibold">User Management</CardTitle>
<CardDescription className="text-sm">
Administrators and Management team members
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
</CardDescription>
</div>
</div>
<Badge variant="outline" className="text-sm">
{elevatedUsers.length} user{elevatedUsers.length !== 1 ? 's' : ''}
</Badge>
<div className="flex items-center gap-3">
<Select value={roleFilter} onValueChange={handleFilterChange}>
<SelectTrigger className="w-[200px] h-10 border rounded-lg border-gray-300">
<SelectValue placeholder="Filter by role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ELEVATED">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-purple-600" />
<span>Elevated ({roleStats.admins + roleStats.management})</span>
</div>
</SelectItem>
<SelectItem value="ADMIN">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-yellow-600" />
<span>Admins ({roleStats.admins})</span>
</div>
</SelectItem>
<SelectItem value="MANAGEMENT">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600" />
<span>Management ({roleStats.management})</span>
</div>
</SelectItem>
<SelectItem value="USER">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-600" />
<span>Users ({roleStats.users})</span>
</div>
</SelectItem>
<SelectItem value="ALL">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-gray-600" />
<span>All Users ({roleStats.admins + roleStats.management + roleStats.users})</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
@ -503,47 +613,114 @@ export function UserRoleManager() {
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
<p className="text-sm text-gray-500">Loading users...</p>
</div>
) : elevatedUsers.length === 0 ? (
) : users.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-gray-400" />
</div>
<p className="font-medium text-gray-700">No elevated users found</p>
<p className="text-sm text-gray-500 mt-1">Assign ADMIN or MANAGEMENT roles to see users here</p>
<p className="font-medium text-gray-700">No users found</p>
<p className="text-sm text-gray-500 mt-1">
{roleFilter === 'ELEVATED'
? 'Assign ADMIN or MANAGEMENT roles to see users here'
: 'No users match the selected filter'
}
</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto pr-2" data-testid="elevated-users-list">
{elevatedUsers.map((user) => (
<div
key={user.userId}
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
data-testid={`elevated-user-${user.email}`}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={`w-10 h-10 rounded-lg ${getRoleBadgeColor(user.role)} flex items-center justify-center shadow-sm`}>
{getRoleIcon(user.role)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 truncate">{user.displayName}</p>
<p className="text-sm text-gray-600 truncate">{user.email}</p>
{user.department && (
<p className="text-xs text-gray-500 mt-1 truncate">
{user.department}{user.designation ? `${user.designation}` : ''}
</p>
)}
<>
<div className="space-y-2" data-testid="users-list">
{users.map((user) => (
<div
key={user.userId}
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
data-testid={`user-${user.email}`}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={`w-10 h-10 rounded-lg ${getRoleBadgeColor(user.role)} flex items-center justify-center shadow-sm`}>
{getRoleIcon(user.role)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 truncate">{user.displayName}</p>
<p className="text-sm text-gray-600 truncate">{user.email}</p>
{user.department && (
<p className="text-xs text-gray-500 mt-1 truncate">
{user.department}{user.designation ? `${user.designation}` : ''}
</p>
)}
</div>
</div>
<Badge className={`${getRoleBadgeColor(user.role)} shrink-0`} data-testid={`role-badge-${user.role}`}>
{user.role}
</Badge>
</div>
<Badge className={`${getRoleBadgeColor(user.role)} shrink-0`} data-testid={`role-badge-${user.role}`}>
{user.role}
</Badge>
</div>
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-gray-600">
Showing {((currentPage - 1) * limit) + 1} to {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
data-testid="prev-page-button"
>
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${
currentPage === pageNum
? 'bg-purple-500 hover:bg-purple-600'
: ''
}`}
data-testid={`page-${pageNum}-button`}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
data-testid="next-page-button"
>
Next
</Button>
</div>
</div>
))}
</div>
)}
</>
)}
</CardContent>
</Card>
</Card>
</div>
</div>
);
}

View File

@ -189,7 +189,9 @@ export function AddApproverModal({
// If user was NOT selected via @ search, validate against Okta
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
try {
const searchOktaResults = await searchUsers(emailToAdd, 1);
const response = await searchUsers(emailToAdd, 1);
// Backend returns { success: true, data: [...users] }
const searchOktaResults = response.data?.data || [];
if (searchOktaResults.length === 0) {
// User not found in Okta
@ -310,7 +312,9 @@ export function AddApproverModal({
searchTimer.current = setTimeout(async () => {
try {
const term = value.slice(1); // Remove @ prefix
const results = await searchUsers(term, 10);
const response = await searchUsers(term, 10);
// Backend returns { success: true, data: [...users] }
const results = response.data?.data || [];
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
@ -352,7 +356,7 @@ export function AddApproverModal({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-0">
<DialogContent className="sm:max-w-md min-h-[60vh] max-h-[90vh] flex flex-col p-0">
<button
onClick={handleClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-50"

View File

@ -116,7 +116,9 @@ export function AddSpectatorModal({
// If user was NOT selected via @ search, validate against Okta
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
try {
const searchOktaResults = await searchUsers(emailToAdd, 1);
const response = await searchUsers(emailToAdd, 1);
// Backend returns { success: true, data: [...users] }
const searchOktaResults = response.data?.data || [];
if (searchOktaResults.length === 0) {
// User not found in Okta
@ -223,7 +225,9 @@ export function AddSpectatorModal({
searchTimer.current = setTimeout(async () => {
try {
const term = value.slice(1); // Remove @ prefix
const results = await searchUsers(term, 10);
const response = await searchUsers(term, 10);
// Backend returns { success: true, data: [...users] }
const results = response.data?.data || [];
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
@ -265,7 +269,7 @@ export function AddSpectatorModal({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-0">
<DialogContent className="sm:max-w-md min-h-[60vh] max-h-[90vh] flex flex-col p-0">
<button
onClick={handleClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-50"

View File

@ -475,7 +475,9 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
// Search for the user by email in Okta directory
try {
const searchResults = await searchUsers(approver.email, 1);
const response = await searchUsers(approver.email, 1);
// Backend returns { success: true, data: [...users] }
const searchResults = response.data?.data || [];
if (searchResults.length === 0) {
// User NOT found in Okta directory
@ -700,7 +702,9 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
// Search for user in Okta directory
try {
const searchResults = await searchUsers(emailInput, 1);
const response = await searchUsers(emailInput, 1);
// Backend returns { success: true, data: [...users] }
const searchResults = response.data?.data || [];
if (searchResults.length === 0) {
// User NOT found in Okta directory
@ -1699,7 +1703,9 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
searchTimers.current[index] = setTimeout(async () => {
try {
const term = value.slice(1); // remove leading '@'
const results = await searchUsers(term, 10);
const response = await searchUsers(term, 10);
// Backend returns { success: true, data: [...users] }
const results = response.data?.data || [];
setUserSearchResults(prev => ({ ...prev, [index]: results }));
} catch (err) {
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
@ -2098,8 +2104,10 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
spectatorTimer.current = setTimeout(async () => {
try {
const term = value.slice(1);
const res = await searchUsers(term, 10);
setSpectatorSearchResults(res);
const response = await searchUsers(term, 10);
// Backend returns { success: true, data: [...users] }
const results = response.data?.data || [];
setSpectatorSearchResults(results);
} catch {
setSpectatorSearchResults([]);
} finally {

View File

@ -49,10 +49,16 @@ export async function updateUserRole(userId: string, role: 'USER' | 'MANAGEMENT'
}
/**
* Get users by role
* Get users by role (with pagination)
*/
export async function getUsersByRole(role: 'USER' | 'MANAGEMENT' | 'ADMIN') {
return await apiClient.get('/admin/users/by-role', { params: { role } });
export async function getUsersByRole(
role?: 'USER' | 'MANAGEMENT' | 'ADMIN' | 'ALL' | 'ELEVATED',
page: number = 1,
limit: number = 10
) {
return await apiClient.get('/admin/users/by-role', {
params: { role: role || 'ELEVATED', page, limit }
});
}
/**