Improve admin ban UX, add product cloning, and enhance auth handling
Refines the admin ban page with better dialog state management and feedback during ban/unban actions. Adds a product cloning feature to the products dashboard and updates the product table to support cloning. Improves error handling in ChatDetail for authentication errors, and enhances middleware to handle auth check timeouts and network errors more gracefully. Also updates BanUserCard to validate user ID and ensure correct request formatting.
This commit is contained in:
@@ -32,6 +32,7 @@ export default function AdminBanPage() {
|
|||||||
const [unbanning, setUnbanning] = useState<string | null>(null);
|
const [unbanning, setUnbanning] = useState<string | null>(null);
|
||||||
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
const [blockedUsers, setBlockedUsers] = useState<BlockedUser[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||||
const [banData, setBanData] = useState({
|
const [banData, setBanData] = useState({
|
||||||
telegramUserId: "",
|
telegramUserId: "",
|
||||||
reason: "",
|
reason: "",
|
||||||
@@ -59,7 +60,10 @@ export default function AdminBanPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBanUser = async () => {
|
const handleBanUser = async (e?: React.MouseEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
e?.stopPropagation();
|
||||||
|
|
||||||
if (!banData.telegramUserId) {
|
if (!banData.telegramUserId) {
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -71,15 +75,12 @@ export default function AdminBanPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setBanning(true);
|
setBanning(true);
|
||||||
await fetchClient("/admin/ban", {
|
const response = await fetchClient("/admin/ban", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
body: {
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
telegramUserId: parseInt(banData.telegramUserId),
|
telegramUserId: parseInt(banData.telegramUserId),
|
||||||
reason: banData.additionalDetails || banData.reason || undefined,
|
reason: banData.additionalDetails || banData.reason || undefined,
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -88,7 +89,8 @@ export default function AdminBanPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setBanData({ telegramUserId: "", reason: "", additionalDetails: "" });
|
setBanData({ telegramUserId: "", reason: "", additionalDetails: "" });
|
||||||
fetchBlockedUsers();
|
setBanDialogOpen(false);
|
||||||
|
await fetchBlockedUsers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to ban user:", error);
|
console.error("Failed to ban user:", error);
|
||||||
toast({
|
toast({
|
||||||
@@ -101,7 +103,10 @@ export default function AdminBanPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnbanUser = async (telegramUserId: number) => {
|
const handleUnbanUser = async (telegramUserId: number, e?: React.MouseEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
e?.stopPropagation();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUnbanning(telegramUserId.toString());
|
setUnbanning(telegramUserId.toString());
|
||||||
await fetchClient(`/admin/ban/${telegramUserId}`, {
|
await fetchClient(`/admin/ban/${telegramUserId}`, {
|
||||||
@@ -113,7 +118,7 @@ export default function AdminBanPage() {
|
|||||||
description: "User has been unbanned",
|
description: "User has been unbanned",
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchBlockedUsers();
|
await fetchBlockedUsers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to unban user:", error);
|
console.error("Failed to unban user:", error);
|
||||||
toast({
|
toast({
|
||||||
@@ -235,20 +240,18 @@ export default function AdminBanPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<AlertDialog>
|
<AlertDialog
|
||||||
|
open={banDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!banning) {
|
||||||
|
setBanDialogOpen(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" disabled={banning}>
|
<Button variant="destructive" disabled={banning}>
|
||||||
{banning ? (
|
<Ban className="h-4 w-4 mr-2" />
|
||||||
<>
|
Ban User
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Banning...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ban className="h-4 w-4 mr-2" />
|
|
||||||
Ban User
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -259,9 +262,23 @@ export default function AdminBanPage() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={banning}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleBanUser}>
|
<AlertDialogAction
|
||||||
Confirm Ban
|
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>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -354,9 +371,19 @@ export default function AdminBanPage() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={unbanning === user.telegramUserId.toString()}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={() => handleUnbanUser(user.telegramUserId)}>
|
<AlertDialogAction
|
||||||
Confirm Unban
|
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>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -282,6 +282,31 @@ export default function ProductsPage() {
|
|||||||
setAddProductOpen(true);
|
setAddProductOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clone product - opens modal with product data but as a new product
|
||||||
|
const handleCloneProduct = (product: Product) => {
|
||||||
|
// Clone the product but remove _id and image to make it a new product
|
||||||
|
const clonedProduct: Product = {
|
||||||
|
...product,
|
||||||
|
_id: undefined, // Remove ID so it's treated as a new product
|
||||||
|
name: `${product.name} (Copy)`, // Add "(Copy)" to the name
|
||||||
|
image: null, // Clear image so user can upload a new one
|
||||||
|
pricing: product.pricing
|
||||||
|
? product.pricing.map((tier) => ({
|
||||||
|
minQuantity: tier.minQuantity,
|
||||||
|
pricePerUnit: tier.pricePerUnit,
|
||||||
|
}))
|
||||||
|
: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||||
|
costPerUnit: product.costPerUnit || 0,
|
||||||
|
// Reset stock to defaults for cloned product
|
||||||
|
currentStock: 0,
|
||||||
|
stockStatus: 'out_of_stock' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
setProductData(clonedProduct);
|
||||||
|
setEditing(false); // Set to false so it creates a new product
|
||||||
|
setAddProductOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Reset product data when adding a new product
|
// Reset product data when adding a new product
|
||||||
const handleAddNewProduct = () => {
|
const handleAddNewProduct = () => {
|
||||||
setProductData({
|
setProductData({
|
||||||
@@ -435,6 +460,7 @@ export default function ProductsPage() {
|
|||||||
products={filteredProducts}
|
products={filteredProducts}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onEdit={handleEditProduct}
|
onEdit={handleEditProduct}
|
||||||
|
onClone={handleCloneProduct}
|
||||||
onDelete={handleDeleteProduct}
|
onDelete={handleDeleteProduct}
|
||||||
onToggleEnabled={handleToggleEnabled}
|
onToggleEnabled={handleToggleEnabled}
|
||||||
onProfitAnalysis={handleProfitAnalysis}
|
onProfitAnalysis={handleProfitAnalysis}
|
||||||
|
|||||||
@@ -13,9 +13,20 @@ export default function BanUserCard() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
|
const userId = parseInt(telegramUserId);
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
setMessage("Invalid Telegram User ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
await fetchClient("/admin/ban", {
|
await fetchClient("/admin/ban", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { telegramUserId, reason }
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
telegramUserId: userId,
|
||||||
|
reason: reason || undefined
|
||||||
|
})
|
||||||
});
|
});
|
||||||
setMessage("User banned");
|
setMessage("User banned");
|
||||||
setTelegramUserId("");
|
setTelegramUserId("");
|
||||||
|
|||||||
@@ -277,9 +277,17 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottomHandler();
|
scrollToBottomHandler();
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error fetching chat data:", error);
|
console.error("Error fetching chat data:", error);
|
||||||
toast.error("Failed to load chat");
|
|
||||||
|
// Don't redirect on auth errors - let the middleware handle it
|
||||||
|
// Only show error toast for non-auth errors
|
||||||
|
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
||||||
|
// Auth errors will be handled by middleware, don't show toast
|
||||||
|
console.log("Auth error detected, middleware will handle redirect");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to load chat");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -352,8 +360,14 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Error polling new messages:", error);
|
console.error("Error polling new messages:", error);
|
||||||
|
|
||||||
|
// Silently fail on auth errors during polling - don't disrupt the user
|
||||||
|
if (error?.message?.includes('401') || error?.message?.includes('403')) {
|
||||||
|
console.log("Auth error during polling, stopping poll");
|
||||||
|
return;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isPollingRef.current = false;
|
isPollingRef.current = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Edit, Trash, AlertTriangle, CheckCircle, AlertCircle, Calculator } from "lucide-react";
|
import { Edit, Trash, AlertTriangle, CheckCircle, AlertCircle, Calculator, Copy } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Product } from "@/models/products";
|
import { Product } from "@/models/products";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -9,6 +9,7 @@ interface ProductTableProps {
|
|||||||
products: Product[];
|
products: Product[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onEdit: (product: Product) => void;
|
onEdit: (product: Product) => void;
|
||||||
|
onClone?: (product: Product) => void;
|
||||||
onDelete: (productId: string) => void;
|
onDelete: (productId: string) => void;
|
||||||
onToggleEnabled: (productId: string, enabled: boolean) => void;
|
onToggleEnabled: (productId: string, enabled: boolean) => void;
|
||||||
onProfitAnalysis?: (productId: string, productName: string) => void;
|
onProfitAnalysis?: (productId: string, productName: string) => void;
|
||||||
@@ -19,6 +20,7 @@ const ProductTable = ({
|
|||||||
products,
|
products,
|
||||||
loading,
|
loading,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onClone,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
onProfitAnalysis,
|
onProfitAnalysis,
|
||||||
@@ -109,7 +111,18 @@ const ProductTable = ({
|
|||||||
<Calculator className="h-4 w-4" />
|
<Calculator className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm" onClick={() => onEdit(product)}>
|
{onClone && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onClone(product)}
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20"
|
||||||
|
title="Clone Listing"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onEdit(product)} title="Edit Product">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -117,6 +130,7 @@ const ProductTable = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onDelete(product._id as string)}
|
onClick={() => onDelete(product._id as string)}
|
||||||
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||||
|
title="Delete Product"
|
||||||
>
|
>
|
||||||
<Trash className="h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -46,12 +46,28 @@ export async function middleware(req: NextRequest) {
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(authCheckUrl, {
|
let res: Response;
|
||||||
method: "GET",
|
try {
|
||||||
headers,
|
res = await fetch(authCheckUrl, {
|
||||||
credentials: 'include',
|
method: "GET",
|
||||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
headers,
|
||||||
});
|
credentials: 'include',
|
||||||
|
signal: AbortSignal.timeout(15000), // 15 second timeout (increased for slower connections)
|
||||||
|
});
|
||||||
|
} catch (fetchError) {
|
||||||
|
// Handle timeout or network errors gracefully
|
||||||
|
console.error("Middleware: Auth check request failed:", fetchError);
|
||||||
|
|
||||||
|
// If it's a timeout or network error, don't redirect - let the request proceed
|
||||||
|
// The page will handle auth errors client-side
|
||||||
|
if (fetchError instanceof Error && fetchError.name === 'TimeoutError') {
|
||||||
|
console.log("Middleware: Auth check timed out, allowing request to proceed");
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other network errors, redirect to login
|
||||||
|
return NextResponse.redirect(new URL("/auth/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Middleware: Auth check responded with status ${res.status}`);
|
console.log(`Middleware: Auth check responded with status ${res.status}`);
|
||||||
|
|
||||||
@@ -63,9 +79,11 @@ export async function middleware(req: NextRequest) {
|
|||||||
console.log("Middleware: Auth check successful");
|
console.log("Middleware: Auth check successful");
|
||||||
|
|
||||||
// Admin-only protection for /dashboard/admin routes
|
// Admin-only protection for /dashboard/admin routes
|
||||||
|
// Clone the response before reading it to avoid consuming the body
|
||||||
if (pathname.startsWith('/dashboard/admin')) {
|
if (pathname.startsWith('/dashboard/admin')) {
|
||||||
try {
|
try {
|
||||||
const user = await res.json();
|
const clonedRes = res.clone();
|
||||||
|
const user = await clonedRes.json();
|
||||||
const username = user?.vendor?.username;
|
const username = user?.vendor?.username;
|
||||||
if (username !== 'admin1') {
|
if (username !== 'admin1') {
|
||||||
console.log("Middleware: Non-admin attempted to access /dashboard/admin, redirecting");
|
console.log("Middleware: Non-admin attempted to access /dashboard/admin, redirecting");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "93ec3d3",
|
"commitHash": "2db13cc",
|
||||||
"buildTime": "2025-12-17T23:21:50.682Z"
|
"buildTime": "2025-12-27T20:56:32.712Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user