Re_Figma_Code/src/components/admin/UserManagement/UserManagement.tsx

693 lines
25 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Plus,
Search,
Users,
Shield,
Loader2,
CheckCircle,
AlertCircle,
Crown,
User as UserIcon,
} from 'lucide-react';
import { userApi } from '@/services/userApi';
import { toast } from 'sonner';
import { UserTable } from './UserTable';
import { UserStatsCards } from './UserStatsCards';
// Simple debounce function
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
interface OktaUser {
userId: string;
email: string;
firstName?: string;
lastName?: string;
displayName?: string;
department?: string;
designation?: string;
}
export interface User {
userId: string;
email: string;
displayName: string;
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
department?: string;
designation?: string;
isActive?: boolean;
}
export function UserManagement() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<OktaUser[]>([]);
const [searching, setSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<OktaUser | null>(null);
const [selectedRole, setSelectedRole] = useState<'USER' | 'MANAGEMENT' | 'ADMIN'>('USER');
const [updating, setUpdating] = useState(false);
const [fetchingRole, setFetchingRole] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Users list with filtering and pagination
const [users, setUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
// 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
const searchContainerRef = useRef<HTMLDivElement>(null);
// Search users from Okta
const searchUsers = useCallback(
debounce(async (query: string) => {
// Only trigger search when using @ sign
if (!query || !query.startsWith('@') || query.length < 2) {
setSearchResults([]);
setSearching(false);
return;
}
setSearching(true);
try {
const term = query.slice(1); // Remove @ prefix
const response = await userApi.searchUsers(term, 20);
const users = response.data?.data || [];
setSearchResults(users);
} catch (error: any) {
console.error('Search failed:', error);
setMessage({
type: 'error',
text: error.response?.data?.message || 'Failed to search users'
});
} finally {
setSearching(false);
}
}, 300),
[]
);
// Handle search input
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setSearchQuery(query);
searchUsers(query);
};
// Fetch user's current role
const fetchUserRole = async (email: string): Promise<'USER' | 'MANAGEMENT' | 'ADMIN' | null> => {
try {
// First check if user exists in current users list
const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (existingUser) {
return existingUser.role;
}
// If not found, try to fetch from backend by checking all users
// We'll search with a broader filter to find the user
const response = await userApi.getUsersByRole('ALL', 1, 1000);
const allUsers = response.data?.data?.users || [];
const foundUser = allUsers.find((u: any) =>
u.email?.toLowerCase() === email.toLowerCase()
);
if (foundUser && foundUser.role) {
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
}
return null; // User not found in system, no role assigned
} catch (error) {
console.error('Failed to fetch user role:', error);
return null;
}
};
// Select user from search results
const handleSelectUser = async (user: OktaUser) => {
setSelectedUser(user);
setSearchQuery(user.email);
setSearchResults([]);
setFetchingRole(true);
try {
// Fetch and set the user's current role if they have one
const currentRole = await fetchUserRole(user.email);
if (currentRole) {
setSelectedRole(currentRole);
} else {
// Default to USER if no role found
setSelectedRole('USER');
}
} catch (error) {
console.error('Failed to fetch user role:', error);
setSelectedRole('USER'); // Default on error
} finally {
setFetchingRole(false);
}
};
// Assign role to user
const handleAssignRole = async () => {
if (!selectedUser || !selectedRole) {
setMessage({ type: 'error', text: 'Please select a user and role' });
return;
}
setUpdating(true);
setMessage(null);
try {
await userApi.assignRole(selectedUser.email, selectedRole);
setMessage({
type: 'success',
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
});
// Reset form
setSelectedUser(null);
setSearchQuery('');
setSelectedRole('USER');
// Refresh the users list
await fetchUsers();
await fetchRoleStatistics();
toast.success(`Role assigned successfully`);
} catch (error: any) {
console.error('Role assignment failed:', error);
const errorMsg = error.response?.data?.error || 'Failed to assign role';
setMessage({
type: 'error',
text: errorMsg
});
toast.error(errorMsg);
} finally {
setUpdating(false);
}
};
// Fetch users with filtering and pagination
const fetchUsers = async (page: number = currentPage) => {
setLoadingUsers(true);
try {
const response = await userApi.getUsersByRole(roleFilter, page, limit);
const usersData = response.data?.data?.users || [];
const paginationData = response.data?.data?.pagination;
const summaryData = response.data?.data?.summary;
setUsers(usersData.map((u: any) => ({
userId: u.userId,
email: u.email,
displayName: u.displayName || u.email,
role: u.role || 'USER',
department: u.department,
designation: u.designation,
isActive: u.isActive !== false // Default to true if not specified
})));
if (paginationData) {
setCurrentPage(paginationData.currentPage);
setTotalPages(paginationData.totalPages);
setTotalUsers(paginationData.totalUsers);
}
// Update summary stats if available
if (summaryData) {
setRoleStats(prev => ({
...prev,
admins: summaryData.ADMIN || 0,
management: summaryData.MANAGEMENT || 0,
users: summaryData.USER || 0,
total: (summaryData.ADMIN || 0) + (summaryData.MANAGEMENT || 0) + (summaryData.USER || 0)
}));
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error('Failed to load users');
} finally {
setLoadingUsers(false);
}
};
// Fetch role statistics
const fetchRoleStatistics = async () => {
try {
const response = await userApi.getRoleStatistics();
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
const stats = {
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
};
setRoleStats(prev => ({
...prev,
...stats,
total: stats.admins + stats.management + stats.users,
active: prev.active || stats.admins + stats.management + stats.users,
inactive: prev.inactive || 0
}));
} catch (error) {
console.error('Failed to fetch statistics:', error);
}
};
// Load data on mount and when filter changes
useEffect(() => {
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 edit user role
const handleEditUser = async (userId: string, newRole: 'USER' | 'MANAGEMENT' | 'ADMIN') => {
try {
await userApi.updateUserRole(userId, newRole);
toast.success('User role updated successfully');
await fetchUsers();
await fetchRoleStatistics();
} catch (error: any) {
console.error('Failed to update user role:', error);
toast.error(error.response?.data?.error || 'Failed to update user role');
}
};
// Handle toggle user status (placeholder - needs backend support)
const handleToggleUserStatus = async (userId: string) => {
const user = users.find(u => u.userId === userId);
if (!user) return;
toast.info('User status toggle functionality coming soon');
};
// Handle delete user (placeholder - needs backend support)
const handleDeleteUser = async (userId: string) => {
const user = users.find(u => u.userId === userId);
if (!user) return;
if (user.role === 'ADMIN') {
toast.error('Cannot delete admin user');
return;
}
toast.info('User deletion functionality coming soon');
};
// Handle click outside to close search results
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
setSearchResults([]);
}
};
if (searchResults.length > 0) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [searchResults]);
// Calculate stats for UserStatsCards
const stats = {
total: roleStats.total,
active: roleStats.active,
inactive: roleStats.inactive,
admins: roleStats.admins
};
return (
<div className="space-y-6">
{/* Statistics Cards */}
<UserStatsCards stats={stats} />
{/* Assign Role Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Assign User Role</CardTitle>
<CardDescription>
Search for a user in Okta and assign them a role
</CardDescription>
</div>
<Button onClick={handleAssignRole} disabled={!selectedUser || updating} className="bg-re-green hover:bg-re-green/90">
{updating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Assigning...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Assign Role
</>
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Search Input */}
<div className="space-y-2" ref={searchContainerRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Type @ to search users..."
value={searchQuery}
onChange={handleSearchChange}
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
/>
{searching && (
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
)}
</div>
</div>
<p className="text-xs text-muted-foreground">Start with @ to search users (e.g., @john)</p>
{/* Search Results Dropdown */}
{searchResults.length > 0 && (
<div className="border rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
<div className="sticky top-0 bg-muted px-4 py-2 border-b">
<p className="text-xs font-semibold text-muted-foreground">
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
</p>
</div>
<div className="p-3">
{searchResults.map((user) => (
<button
key={user.userId}
onClick={() => handleSelectUser(user)}
className="w-full text-left p-3 hover:bg-muted rounded-lg transition-colors mb-1 last:mb-0"
>
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
{user.department && (
<p className="text-xs text-muted-foreground mt-1">
{user.department}{user.designation ? `${user.designation}` : ''}
</p>
)}
</button>
))}
</div>
</div>
)}
</div>
{/* Selected User */}
{selectedUser && (
<div className="border-2 border-re-green/20 bg-re-green/5 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-re-green flex items-center justify-center text-white font-bold shadow-md">
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold text-gray-900">
{selectedUser.displayName || selectedUser.email}
</p>
<p className="text-sm text-muted-foreground">{selectedUser.email}</p>
{selectedUser.department && (
<p className="text-xs text-muted-foreground mt-1">
{selectedUser.department}{selectedUser.designation ? `${selectedUser.designation}` : ''}
</p>
)}
{fetchingRole && (
<p className="text-xs text-re-green mt-1 flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
Checking current role...
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedUser(null);
setSearchQuery('');
setSelectedRole('USER');
setFetchingRole(false);
}}
>
Clear
</Button>
</div>
</div>
)}
{/* Role Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Select Role</label>
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)} disabled={fetchingRole}>
<SelectTrigger className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20">
<SelectValue placeholder={fetchingRole ? "Loading current role..." : "Select role"} />
</SelectTrigger>
<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" 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" 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>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Message */}
{message && (
<div className={`border-2 rounded-lg p-4 ${message.type === 'success'
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<div className="flex items-start gap-3">
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
)}
<p className={`text-sm ${message.type === 'success' ? 'text-green-800' : 'text-red-800'}`}>
{message.text}
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Users List with Filter and Pagination */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-re-green" />
User Management
</CardTitle>
<CardDescription>
View and manage user accounts and roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Select value={roleFilter} onValueChange={handleFilterChange}>
<SelectTrigger className="w-[200px] border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20">
<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">
{loadingUsers ? (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-re-green mb-2" />
<p className="text-sm text-muted-foreground">Loading users...</p>
</div>
) : 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 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>
) : (
<>
<UserTable
users={users.map(u => ({
id: u.userId,
name: u.displayName,
email: u.email,
role: u.role, // Keep as ADMIN, MANAGEMENT, or USER
department: u.department || 'N/A',
status: u.isActive ? 'active' : 'inactive'
}))}
onEdit={(userId) => {
const user = users.find(u => u.userId === userId);
if (user) {
// Open role selection dialog
const newRole = user.role === 'USER' ? 'MANAGEMENT' : user.role === 'MANAGEMENT' ? 'ADMIN' : 'USER';
handleEditUser(userId, newRole);
}
}}
onToggleStatus={handleToggleUserStatus}
onDelete={handleDeleteUser}
/>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t mt-4">
<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}
>
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-re-green hover:bg-re-green/90'
: ''
}`}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}