Add admin challenge management features: Implement functions for canceling and reactivating challenges, enhance error handling, and update the ChallengeManagement component to support these actions. Update API to retrieve all challenge statuses for admin and improve UI to display active challenges count.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m16s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m16s
This commit is contained in:
@@ -166,3 +166,71 @@ export async function deleteChallenge(challengeId: string) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminCancelChallenge(challengeId: string) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.adminCancelChallenge(challengeId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi annulé avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Admin cancel challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'annulation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function reactivateChallenge(challengeId: string) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.reactivateChallenge(challengeId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi réactivé avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Reactivate challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de la réactivation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,8 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer tous les défis (PENDING et ACCEPTED) pour l'admin
|
||||
const allChallenges = await challengeService.getAllChallenges();
|
||||
// Filtrer pour ne garder que PENDING et ACCEPTED
|
||||
const challenges = allChallenges.filter(
|
||||
(c) => c.status === "PENDING" || c.status === "ACCEPTED"
|
||||
);
|
||||
// Récupérer tous les défis pour l'admin (PENDING, ACCEPTED, CANCELLED, COMPLETED, REJECTED)
|
||||
const challenges = await challengeService.getAllChallenges();
|
||||
|
||||
return NextResponse.json(challenges);
|
||||
} catch (error) {
|
||||
|
||||
23
app/api/challenges/active-count/route.ts
Normal file
23
app/api/challenges/active-count/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
|
||||
const count = await challengeService.getActiveChallengesCount(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return NextResponse.json({ count });
|
||||
} catch (error) {
|
||||
console.error("Error fetching active challenges count:", error);
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
rejectChallenge,
|
||||
updateChallenge,
|
||||
deleteChallenge,
|
||||
adminCancelChallenge,
|
||||
reactivateChallenge,
|
||||
} from "@/actions/admin/challenges";
|
||||
import { Button, Card, Input, Textarea, Alert } from "@/components/ui";
|
||||
import { Avatar } from "@/components/ui";
|
||||
@@ -178,6 +180,44 @@ export default function ChallengeManagement() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = async (challengeId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await adminCancelChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi annulé avec succès");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'annulation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReactivate = async (challengeId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir réactiver ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reactivateChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi réactivé avec succès");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la réactivation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-pixel-gold py-8">Chargement...</div>
|
||||
@@ -185,15 +225,18 @@ export default function ChallengeManagement() {
|
||||
}
|
||||
|
||||
if (challenges.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucun défi en attente
|
||||
</div>
|
||||
);
|
||||
return <div className="text-center text-gray-400 py-8">Aucun défi</div>;
|
||||
}
|
||||
|
||||
const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED");
|
||||
const pendingChallenges = challenges.filter((c) => c.status === "PENDING");
|
||||
const cancelledChallenges = challenges.filter(
|
||||
(c) => c.status === "CANCELLED"
|
||||
);
|
||||
const completedChallenges = challenges.filter(
|
||||
(c) => c.status === "COMPLETED"
|
||||
);
|
||||
const rejectedChallenges = challenges.filter((c) => c.status === "REJECTED");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -208,15 +251,41 @@ export default function ChallengeManagement() {
|
||||
</Alert>
|
||||
)}
|
||||
<div className="text-sm text-gray-400 mb-4">
|
||||
{acceptedChallenges.length} défi
|
||||
{acceptedChallenges.length > 1 ? "s" : ""} en attente de validation
|
||||
{acceptedChallenges.length > 0 && (
|
||||
<span>
|
||||
{acceptedChallenges.length} défi
|
||||
{acceptedChallenges.length > 1 ? "s" : ""} en attente de désignation
|
||||
du gagnant
|
||||
</span>
|
||||
)}
|
||||
{pendingChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
<span className={acceptedChallenges.length > 0 ? "ml-2" : ""}>
|
||||
• {pendingChallenges.length} défi
|
||||
{pendingChallenges.length > 1 ? "s" : ""} en attente
|
||||
d'acceptation
|
||||
</span>
|
||||
)}
|
||||
{cancelledChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {cancelledChallenges.length} défi
|
||||
{cancelledChallenges.length > 1 ? "s" : ""} annulé
|
||||
{cancelledChallenges.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{completedChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {completedChallenges.length} défi
|
||||
{completedChallenges.length > 1 ? "s" : ""} complété
|
||||
{completedChallenges.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{rejectedChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {rejectedChallenges.length} défi
|
||||
{rejectedChallenges.length > 1 ? "s" : ""} rejeté
|
||||
{rejectedChallenges.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{challenges.map((challenge) => (
|
||||
@@ -260,13 +329,27 @@ export default function ChallengeManagement() {
|
||||
<span
|
||||
className={`px-2 py-1 rounded ${
|
||||
challenge.status === "ACCEPTED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: challenge.status === "COMPLETED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: challenge.status === "CANCELLED"
|
||||
? "bg-gray-500/20 text-gray-400"
|
||||
: challenge.status === "REJECTED"
|
||||
? "bg-red-500/20 text-red-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{challenge.status === "ACCEPTED"
|
||||
? "Accepté"
|
||||
: "En attente d'acceptation"}
|
||||
{challenge.status === "PENDING"
|
||||
? "En attente d'acceptation"
|
||||
: challenge.status === "ACCEPTED"
|
||||
? "En cours - En attente de désignation du gagnant"
|
||||
: challenge.status === "COMPLETED"
|
||||
? "Complété"
|
||||
: challenge.status === "CANCELLED"
|
||||
? "Annulé"
|
||||
: challenge.status === "REJECTED"
|
||||
? "Rejeté"
|
||||
: challenge.status}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.acceptedAt && (
|
||||
@@ -291,7 +374,28 @@ export default function ChallengeManagement() {
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Valider/Rejeter
|
||||
Désigner le gagnant
|
||||
</Button>
|
||||
)}
|
||||
{challenge.status !== "CANCELLED" &&
|
||||
challenge.status !== "COMPLETED" && (
|
||||
<Button
|
||||
onClick={() => handleCancel(challenge.id)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
{challenge.status === "CANCELLED" && (
|
||||
<Button
|
||||
onClick={() => handleReactivate(challenge.id)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Réactiver
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -328,7 +432,7 @@ export default function ChallengeManagement() {
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
|
||||
Valider/Rejeter le défi
|
||||
Désigner le gagnant
|
||||
</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -418,7 +522,7 @@ export default function ChallengeManagement() {
|
||||
disabled={!winnerId || isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Validation..." : "Valider le défi"}
|
||||
{isPending ? "Enregistrement..." : "Confirmer le gagnant"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReject}
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function ChallengesSection({
|
||||
const result = await acceptChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi accepté ! En attente de validation admin.");
|
||||
setSuccessMessage("Défi accepté ! En attente de désignation du gagnant.");
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
@@ -174,7 +174,7 @@ export default function ChallengesSection({
|
||||
case "PENDING":
|
||||
return "En attente d'acceptation";
|
||||
case "ACCEPTED":
|
||||
return "Accepté - En attente de validation admin";
|
||||
return "En cours - En attente de désignation du gagnant";
|
||||
case "COMPLETED":
|
||||
return "Complété";
|
||||
case "REJECTED":
|
||||
|
||||
72
components/navigation/ChallengeBadge.tsx
Normal file
72
components/navigation/ChallengeBadge.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ChallengeBadgeProps {
|
||||
initialCount?: number;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export default function ChallengeBadge({
|
||||
initialCount = 0,
|
||||
onNavigate,
|
||||
}: ChallengeBadgeProps) {
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
useEffect(() => {
|
||||
// Récupérer le nombre de défis actifs
|
||||
const fetchActiveCount = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/challenges/active-count");
|
||||
const data = await response.json();
|
||||
setCount(data.count || 0);
|
||||
} catch (error) {
|
||||
console.error("Error fetching active challenges count:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchActiveCount();
|
||||
|
||||
// Rafraîchir toutes les 30 secondes
|
||||
const interval = setInterval(fetchActiveCount, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/challenges"
|
||||
onClick={onNavigate}
|
||||
className={`inline-flex items-center gap-1.5 transition text-xs font-normal uppercase tracking-widest ${
|
||||
onNavigate ? "py-2" : ""
|
||||
}`}
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--foreground)")
|
||||
}
|
||||
title={
|
||||
count > 0
|
||||
? `${count} défi${count > 1 ? "s" : ""} actif${count > 1 ? "s" : ""}`
|
||||
: "Défis"
|
||||
}
|
||||
>
|
||||
<span>DÉFIS</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-full text-[10px] font-bold leading-none"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--background)",
|
||||
}}
|
||||
>
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import PlayerStats from "@/components/profile/PlayerStats";
|
||||
import { Button, ThemeToggle } from "@/components/ui";
|
||||
import ChallengeBadge from "./ChallengeBadge";
|
||||
|
||||
interface UserData {
|
||||
username: string;
|
||||
@@ -20,11 +21,13 @@ interface UserData {
|
||||
interface NavigationProps {
|
||||
initialUserData?: UserData | null;
|
||||
initialIsAdmin?: boolean;
|
||||
initialActiveChallengesCount?: number;
|
||||
}
|
||||
|
||||
export default function Navigation({
|
||||
initialUserData,
|
||||
initialIsAdmin,
|
||||
initialActiveChallengesCount = 0,
|
||||
}: NavigationProps) {
|
||||
const { data: session } = useSession();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -114,19 +117,7 @@ export default function Navigation({
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/challenges"
|
||||
className="transition text-xs font-normal uppercase tracking-widest"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--foreground)")
|
||||
}
|
||||
>
|
||||
DÉFIS
|
||||
</Link>
|
||||
<ChallengeBadge initialCount={initialActiveChallengesCount} />
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
@@ -287,20 +278,10 @@ export default function Navigation({
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/challenges"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="transition text-xs font-normal uppercase tracking-widest py-2"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--foreground)")
|
||||
}
|
||||
>
|
||||
DÉFIS
|
||||
</Link>
|
||||
<ChallengeBadge
|
||||
initialCount={initialActiveChallengesCount}
|
||||
onNavigate={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
interface UserData {
|
||||
@@ -17,6 +18,7 @@ export default async function NavigationWrapper() {
|
||||
|
||||
let userData: UserData | null = null;
|
||||
const isAdmin = session?.user?.role === "ADMIN";
|
||||
let activeChallengesCount = 0;
|
||||
|
||||
if (session?.user?.id) {
|
||||
const user = await userService.getUserById(session.user.id, {
|
||||
@@ -32,7 +34,17 @@ export default async function NavigationWrapper() {
|
||||
if (user) {
|
||||
userData = user;
|
||||
}
|
||||
|
||||
// Récupérer le nombre de défis actifs
|
||||
activeChallengesCount =
|
||||
await challengeService.getActiveChallengesCount(session.user.id);
|
||||
}
|
||||
|
||||
return <Navigation initialUserData={userData} initialIsAdmin={isAdmin} />;
|
||||
return (
|
||||
<Navigation
|
||||
initialUserData={userData}
|
||||
initialIsAdmin={isAdmin}
|
||||
initialActiveChallengesCount={activeChallengesCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,6 +306,63 @@ export class ChallengeService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule un défi (admin seulement)
|
||||
*/
|
||||
async adminCancelChallenge(challengeId: string): Promise<Challenge> {
|
||||
const challenge = await prisma.challenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
throw new NotFoundError("Défi");
|
||||
}
|
||||
|
||||
// Vérifier que le défi peut être annulé
|
||||
if (challenge.status === "COMPLETED") {
|
||||
throw new ValidationError("Un défi complété ne peut pas être annulé");
|
||||
}
|
||||
|
||||
return prisma.challenge.update({
|
||||
where: { id: challengeId },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Réactive un défi annulé (admin seulement)
|
||||
* Remet le défi en PENDING s'il n'avait jamais été accepté, sinon en ACCEPTED
|
||||
*/
|
||||
async reactivateChallenge(challengeId: string): Promise<Challenge> {
|
||||
const challenge = await prisma.challenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
throw new NotFoundError("Défi");
|
||||
}
|
||||
|
||||
// Vérifier que le défi est annulé
|
||||
if (challenge.status !== "CANCELLED") {
|
||||
throw new ValidationError(
|
||||
"Seuls les défis annulés peuvent être réactivés"
|
||||
);
|
||||
}
|
||||
|
||||
// Si le défi avait été accepté avant, le remettre en ACCEPTED
|
||||
// Sinon, le remettre en PENDING
|
||||
const newStatus = challenge.acceptedAt ? "ACCEPTED" : "PENDING";
|
||||
|
||||
return prisma.challenge.update({
|
||||
where: { id: challengeId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un défi (admin seulement)
|
||||
*/
|
||||
@@ -405,7 +462,7 @@ export class ChallengeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les défis en attente de validation admin
|
||||
* Récupère les défis acceptés en attente de désignation du gagnant
|
||||
*/
|
||||
async getPendingValidationChallenges(): Promise<ChallengeWithUsers[]> {
|
||||
return prisma.challenge.findMany({
|
||||
@@ -489,6 +546,30 @@ export class ChallengeService {
|
||||
take: options?.take,
|
||||
}) as Promise<ChallengeWithUsers[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les défis actifs pour un utilisateur
|
||||
* - PENDING où l'utilisateur est challenged (en attente de sa réponse)
|
||||
* - ACCEPTED où l'utilisateur est challenger ou challenged (en cours, en attente de validation)
|
||||
*/
|
||||
async getActiveChallengesCount(userId: string): Promise<number> {
|
||||
return prisma.challenge.count({
|
||||
where: {
|
||||
OR: [
|
||||
// Défis en attente de réponse de l'utilisateur
|
||||
{
|
||||
status: "PENDING",
|
||||
challengedId: userId,
|
||||
},
|
||||
// Défis acceptés en cours (en attente de désignation du gagnant)
|
||||
{
|
||||
status: "ACCEPTED",
|
||||
OR: [{ challengerId: userId }, { challengedId: userId }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const challengeService = new ChallengeService();
|
||||
|
||||
Reference in New Issue
Block a user