Files
fast-next-template/frontend/src/components/admin/users/UserListTable.tsx
Felipe Cardoso 95342cc94d fix(mcp-gateway): address critical issues from deep review
Frontend:
- Fix debounce race condition in UserListTable search handler
- Use useRef to properly track and cleanup timeout between keystrokes

Backend (LLM Gateway):
- Add thread-safe double-checked locking for global singletons
  (providers, circuit registry, cost tracker)
- Fix Redis URL parsing with proper urlparse validation
- Add explicit error handling for malformed Redis URLs
- Document circuit breaker state transition safety

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 01:36:55 +01:00

295 lines
10 KiB
TypeScript

/**
* UserListTable Component
* Displays paginated list of users with search, filters, sorting, and bulk selection
*/
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { format } from 'date-fns';
import { Check, X } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { UserActionMenu } from './UserActionMenu';
import type { User, PaginationMeta } from '@/lib/api/hooks/useAdmin';
interface UserListTableProps {
users: User[];
pagination: PaginationMeta;
isLoading: boolean;
selectedUsers: string[];
onSelectUser: (userId: string) => void;
onSelectAll: (selected: boolean) => void;
onPageChange: (page: number) => void;
onSearch: (search: string) => void;
onFilterActive: (filter: string | null) => void;
onFilterSuperuser: (filter: string | null) => void;
onEditUser?: (user: User) => void;
currentUserId?: string;
}
export function UserListTable({
users,
pagination,
isLoading,
selectedUsers,
onSelectUser,
onSelectAll,
onPageChange,
onSearch,
onFilterActive,
onFilterSuperuser,
onEditUser,
currentUserId,
}: UserListTableProps) {
const [searchValue, setSearchValue] = useState('');
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
// Debounce search with proper cleanup
const handleSearchChange = useCallback(
(value: string) => {
setSearchValue(value);
// Clear previous timeout to prevent stale searches
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
onSearch(value);
}, 300);
},
[onSearch]
);
const allSelected = users.length > 0 && users.every((user) => selectedUsers.includes(user.id));
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 gap-2">
<Input
placeholder="Search by name or email..."
value={searchValue}
onChange={(e) => handleSearchChange(e.target.value)}
className="max-w-sm"
/>
<Select onValueChange={onFilterActive}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="true">Active</SelectItem>
<SelectItem value="false">Inactive</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={onFilterSuperuser}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
<SelectItem value="true">Superusers</SelectItem>
<SelectItem value="false">Regular</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={allSelected}
onCheckedChange={onSelectAll}
aria-label="Select all users"
disabled={isLoading || users.length === 0}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">Superuser</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[70px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
// Loading skeleton
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-4" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-[60px] mx-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-4 mx-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8" />
</TableCell>
</TableRow>
))
) : users.length === 0 ? (
// Empty state
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
No users found. Try adjusting your filters.
</TableCell>
</TableRow>
) : (
// User rows
users.map((user) => {
const isCurrentUser = currentUserId === user.id;
const fullName = user.last_name
? `${user.first_name} ${user.last_name}`
: user.first_name;
return (
<TableRow key={user.id}>
<TableCell>
<Checkbox
checked={selectedUsers.includes(user.id)}
onCheckedChange={() => onSelectUser(user.id)}
aria-label={`Select ${fullName}`}
disabled={isCurrentUser}
/>
</TableCell>
<TableCell className="font-medium">
{fullName}
{isCurrentUser && (
<Badge variant="outline" className="ml-2">
You
</Badge>
)}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-center">
<Badge variant={user.is_active ? 'default' : 'secondary'}>
{user.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-center">
{user.is_superuser ? (
<Check className="h-4 w-4 mx-auto text-green-600" aria-label="Yes" />
) : (
<X className="h-4 w-4 mx-auto text-muted-foreground" aria-label="No" />
)}
</TableCell>
<TableCell>{format(new Date(user.created_at), 'MMM d, yyyy')}</TableCell>
<TableCell>
<UserActionMenu
user={user}
isCurrentUser={isCurrentUser}
onEdit={onEditUser}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{!isLoading && users.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
{Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
{pagination.total} users
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={!pagination.has_prev}
>
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: pagination.total_pages }, (_, i) => i + 1)
.filter(
(page) =>
page === 1 ||
page === pagination.total_pages ||
Math.abs(page - pagination.page) <= 1
)
.map((page, idx, arr) => {
const prevPage = arr[idx - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<div key={page} className="flex items-center">
{showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
<Button
variant={page === pagination.page ? 'default' : 'outline'}
size="sm"
onClick={() => onPageChange(page)}
className="w-9"
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={!pagination.has_next}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}