Introduces pagination controls and server-side paginated fetching for blocked users, users, and vendors in the admin dashboard. Improves error handling in server API responses and validates order ID in OrderDetailsModal. Updates git-info.json with latest commit metadata.
448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } 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 { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
|
import { UserX, Shield, Search, Ban, Unlock, Loader2 } from "lucide-react";
|
|
import { fetchClient } from "@/lib/api-client";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface BlockedUser {
|
|
_id: string;
|
|
telegramUserId: number;
|
|
reason?: string;
|
|
blockedBy?: {
|
|
_id: string;
|
|
username: string;
|
|
};
|
|
blockedAt: string;
|
|
}
|
|
|
|
export default function AdminBanPage() {
|
|
const { toast } = useToast();
|
|
const [loading, setLoading] = useState(true);
|
|
const [banning, setBanning] = useState(false);
|
|
const [unbanning, setUnbanning] = useState<string | null>(null);
|
|
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [pagination, setPagination] = useState<{
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasNextPage: boolean;
|
|
hasPrevPage: boolean;
|
|
} | null>(null);
|
|
const [banData, setBanData] = useState({
|
|
telegramUserId: "",
|
|
reason: "",
|
|
additionalDetails: "",
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchBlockedUsers();
|
|
}, [page]);
|
|
|
|
const fetchBlockedUsers = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await fetchClient<{
|
|
success: boolean;
|
|
blockedUsers: BlockedUser[];
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasNextPage: boolean;
|
|
hasPrevPage: boolean;
|
|
};
|
|
}>(`/admin/blocked-users?page=${page}&limit=25`);
|
|
setBlockedUsers(data.blockedUsers);
|
|
setPagination(data.pagination);
|
|
} catch (error) {
|
|
console.error("Failed to fetch blocked users:", error);
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to load blocked users",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBanUser = async (e?: React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
|
|
if (!banData.telegramUserId) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Telegram User ID is required",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setBanning(true);
|
|
const response = await fetchClient("/admin/ban", {
|
|
method: "POST",
|
|
body: {
|
|
telegramUserId: parseInt(banData.telegramUserId),
|
|
reason: banData.additionalDetails || banData.reason || undefined,
|
|
},
|
|
});
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: "User has been banned",
|
|
});
|
|
|
|
setBanData({ telegramUserId: "", reason: "", additionalDetails: "" });
|
|
setBanDialogOpen(false);
|
|
await fetchBlockedUsers();
|
|
} catch (error: any) {
|
|
console.error("Failed to ban user:", error);
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || "Failed to ban user",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setBanning(false);
|
|
}
|
|
};
|
|
|
|
const handleUnbanUser = async (telegramUserId: number, e?: React.MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
|
|
try {
|
|
setUnbanning(telegramUserId.toString());
|
|
await fetchClient(`/admin/ban/${telegramUserId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: "User has been unbanned",
|
|
});
|
|
|
|
await fetchBlockedUsers();
|
|
} catch (error: any) {
|
|
console.error("Failed to unban user:", error);
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || "Failed to unban user",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setUnbanning(null);
|
|
}
|
|
};
|
|
|
|
const filteredUsers = blockedUsers.filter((user) => {
|
|
if (!searchQuery) return true;
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
user.telegramUserId.toString().includes(query) ||
|
|
user.reason?.toLowerCase().includes(query) ||
|
|
user.blockedBy?.username?.toLowerCase().includes(query)
|
|
);
|
|
});
|
|
|
|
const activeBans = blockedUsers.length;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Ban Users</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">Manage user bans and suspensions</p>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Active Bans</CardTitle>
|
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{activeBans}</div>
|
|
<p className="text-xs text-muted-foreground">Currently banned</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Bans</CardTitle>
|
|
<UserX className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{activeBans}</div>
|
|
<p className="text-xs text-muted-foreground">All time bans</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Recent Bans</CardTitle>
|
|
<Ban className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{blockedUsers.filter(
|
|
(u) => new Date(u.blockedAt) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
).length}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">Last 7 days</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Ban User Form */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center">
|
|
<UserX className="h-5 w-5 mr-2" />
|
|
Ban User
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Enter Telegram User ID and reason for banning
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="telegramUserId">Telegram User ID</Label>
|
|
<Input
|
|
id="telegramUserId"
|
|
type="number"
|
|
placeholder="Enter Telegram User ID"
|
|
value={banData.telegramUserId}
|
|
onChange={(e) => setBanData({...banData, telegramUserId: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="reason">Reason</Label>
|
|
<Select value={banData.reason} onValueChange={(value) => setBanData({...banData, reason: value})}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select ban reason (optional)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="spam">Spam</SelectItem>
|
|
<SelectItem value="fraud">Fraud</SelectItem>
|
|
<SelectItem value="harassment">Harassment</SelectItem>
|
|
<SelectItem value="policy_violation">Policy Violation</SelectItem>
|
|
<SelectItem value="suspicious_activity">Suspicious Activity</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Additional Details (Optional)</Label>
|
|
<Input
|
|
id="description"
|
|
placeholder="Additional context..."
|
|
value={banData.additionalDetails}
|
|
onChange={(e) => setBanData({...banData, additionalDetails: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4">
|
|
<AlertDialog
|
|
open={banDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!banning) {
|
|
setBanDialogOpen(open);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" disabled={banning}>
|
|
<Ban className="h-4 w-4 mr-2" />
|
|
Ban User
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm Ban</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to ban Telegram User ID "{banData.telegramUserId}"? This action can be reversed later.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={banning}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleBanUser(e);
|
|
}}
|
|
disabled={banning}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{banning ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Banning...
|
|
</>
|
|
) : (
|
|
"Confirm Ban"
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Banned Users Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Banned Users</CardTitle>
|
|
<CardDescription>View and manage current bans</CardDescription>
|
|
</div>
|
|
<div className="relative w-64">
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by User ID, reason, or admin..."
|
|
className="pl-8"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Telegram User ID</TableHead>
|
|
<TableHead>Reason</TableHead>
|
|
<TableHead>Banned By</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredUsers.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
{searchQuery ? "No users found matching your search" : "No banned users"}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredUsers.map((user) => (
|
|
<TableRow key={user._id}>
|
|
<TableCell>
|
|
<div className="font-mono text-sm">{user.telegramUserId}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{user.reason ? (
|
|
<Badge variant="secondary">{user.reason}</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">No reason provided</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{user.blockedBy?.username || "Unknown"}
|
|
</TableCell>
|
|
<TableCell>
|
|
{new Date(user.blockedAt).toLocaleDateString()}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={unbanning === user.telegramUserId.toString()}
|
|
>
|
|
{unbanning === user.telegramUserId.toString() ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Unlock className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm Unban</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to unban Telegram User ID "{user.telegramUserId}"? They will be able to use the platform again.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={unbanning === user.telegramUserId.toString()}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => handleUnbanUser(user.telegramUserId, e)}
|
|
disabled={unbanning === user.telegramUserId.toString()}
|
|
>
|
|
{unbanning === user.telegramUserId.toString() ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Unbanning...
|
|
</>
|
|
) : (
|
|
"Confirm Unban"
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-between px-6 pb-6">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={!pagination.hasPrevPage}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={!pagination.hasNextPage}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
} |