292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { toast } from "sonner";
|
|
import { Shield, Trash2, RefreshCw } from "lucide-react";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
interface BanList {
|
|
bannedIPs: string[];
|
|
bannedUserIds: string[];
|
|
bannedTokens: string[];
|
|
}
|
|
|
|
export const BansManager = ({ session }: { session: any }) => {
|
|
const [banList, setBanList] = useState<BanList>({
|
|
bannedIPs: [],
|
|
bannedUserIds: [],
|
|
bannedTokens: [],
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [unbanTarget, setUnbanTarget] = useState<{ type: 'ip' | 'user'; value: string } | null>(null);
|
|
|
|
const fetchBanList = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/admin/bans`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${session?.access_token || ''}`
|
|
}
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error('Failed to fetch ban list');
|
|
}
|
|
|
|
const data = await res.json();
|
|
setBanList(data);
|
|
} catch (err: any) {
|
|
toast.error("Failed to fetch ban list", {
|
|
description: err.message
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUnban = async () => {
|
|
if (!unbanTarget) return;
|
|
|
|
try {
|
|
const endpoint = unbanTarget.type === 'ip'
|
|
? '/api/admin/bans/unban-ip'
|
|
: '/api/admin/bans/unban-user';
|
|
|
|
const body = unbanTarget.type === 'ip'
|
|
? { ip: unbanTarget.value }
|
|
: { userId: unbanTarget.value };
|
|
|
|
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}${endpoint}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${session?.access_token || ''}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error('Failed to unban');
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
toast.success("Unbanned successfully", {
|
|
description: data.message
|
|
});
|
|
fetchBanList();
|
|
} else {
|
|
toast.warning("Not found", {
|
|
description: data.message
|
|
});
|
|
}
|
|
} catch (err: any) {
|
|
toast.error("Failed to unban", {
|
|
description: err.message
|
|
});
|
|
} finally {
|
|
setUnbanTarget(null);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchBanList();
|
|
}, []);
|
|
|
|
const totalBans = banList.bannedIPs.length + banList.bannedUserIds.length + banList.bannedTokens.length;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-6 w-6" />
|
|
<h1 className="text-2xl font-bold">Ban Management</h1>
|
|
</div>
|
|
<Button onClick={fetchBanList} disabled={loading} variant="outline">
|
|
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Banned IPs</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{banList.bannedIPs.length}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Banned Users</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{banList.bannedUserIds.length}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Banned Tokens</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{banList.bannedTokens.length}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{totalBans === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
No active bans
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{banList.bannedIPs.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Banned IP Addresses</CardTitle>
|
|
<CardDescription>
|
|
IP addresses that have been auto-banned for excessive requests
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>IP Address</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{banList.bannedIPs.map((ip) => (
|
|
<TableRow key={ip}>
|
|
<TableCell className="font-mono">{ip}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setUnbanTarget({ type: 'ip', value: ip })}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Unban
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{banList.bannedUserIds.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Banned Users</CardTitle>
|
|
<CardDescription>
|
|
User accounts that have been auto-banned for excessive requests
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>User ID</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{banList.bannedUserIds.map((userId) => (
|
|
<TableRow key={userId}>
|
|
<TableCell className="font-mono text-sm">{userId}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setUnbanTarget({ type: 'user', value: userId })}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Unban
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{banList.bannedTokens.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Banned Tokens</CardTitle>
|
|
<CardDescription>
|
|
Authentication tokens that have been auto-banned (cannot be unbanned via UI)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Token (truncated)</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{banList.bannedTokens.map((token, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className="font-mono text-sm">
|
|
{token.substring(0, 40)}...
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<AlertDialog open={!!unbanTarget} onOpenChange={(open) => !open && setUnbanTarget(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm Unban</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to unban this {unbanTarget?.type}?
|
|
<div className="mt-2 p-2 bg-muted rounded font-mono text-sm">
|
|
{unbanTarget?.value}
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleUnban}>Unban</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|