feat: add clinic role support to doctor profile access and update API endpoint for clinic owners
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
# دسترسی صاحب کلینیک به پروفایل پزشکان عضو کلینیک
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
وقتی صاحب کلینیک (`ROLE_CLINIC`) در صفحه کلینیک روی آیکون چشم کنار نام یک پزشک کلیک میکند، به جای مشاهده پروفایل پزشک، به داشبورد redirect میشود. دلیل: route `doctors/:uuid` در `App.tsx` فقط `['admin', 'doctor']` را قبول دارد و `RoleRoute` صاحب کلینیک را به `/admin/dashboard` میفرستد.
|
||||||
|
|
||||||
|
## مشکل
|
||||||
|
|
||||||
|
دو لایه مشکل وجود دارد:
|
||||||
|
|
||||||
|
**۱. Frontend — Route permission:**
|
||||||
|
```tsx
|
||||||
|
// assets/admin/App.tsx — خط 141
|
||||||
|
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
||||||
|
```
|
||||||
|
`RoleRoute` فقط `primaryRole` را چک میکند — اگر `clinic` بود، redirect به dashboard میکند.
|
||||||
|
|
||||||
|
**۲. Backend — API permission:**
|
||||||
|
```php
|
||||||
|
// src/Doctor/Controller/DoctorController.php — خط 140-141
|
||||||
|
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||||
|
public function show(string $uuid): JsonResponse // بدون IsGranted — PUBLIC است
|
||||||
|
```
|
||||||
|
`GET /api/v1/doctor/{uuid}` عمومی است، اما برای اطمینان از امنیت باید یک endpoint اختصاصی برای کلینیک وجود داشته باشد که تأیید کند پزشک واقعاً عضو کلینیک مربوطه است.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `assets/admin/App.tsx` | تعریف route و RoleRoute — جای اصلی مشکل frontend |
|
||||||
|
| `assets/admin/pages/DoctorDetailPage.tsx` | صفحه پروفایل پزشک |
|
||||||
|
| `src/Doctor/Controller/DoctorController.php` | `GET /api/v1/doctor/{uuid}` |
|
||||||
|
| `src/Clinic/Repository/ClinicRepository.php` | دارای `findByDoctor()` و `findByUser()` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// App.tsx خط 141
|
||||||
|
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
||||||
|
|
||||||
|
// RoleRoute خط 70-74
|
||||||
|
function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) {
|
||||||
|
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||||
|
if (!primaryRole) return <div>در حال بارگذاری...</div>;
|
||||||
|
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// DoctorController.php خط 140-163 — PUBLIC, no auth check
|
||||||
|
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||||
|
public function show(string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||||
|
if ($doctor === null) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||||
|
}
|
||||||
|
// ... بدون چک مالکیت یا عضویت در کلینیک
|
||||||
|
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. Frontend — اضافه کردن `clinic` به RoleRoute مسیر پزشک
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// App.tsx — خط 141 را تغییر بده
|
||||||
|
<Route
|
||||||
|
path="doctors/:uuid"
|
||||||
|
element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. Backend — endpoint اختصاصی برای کلینیک
|
||||||
|
|
||||||
|
یک endpoint جدید در `DoctorController` اضافه کن که صاحب کلینیک بتواند پروفایل پزشکان عضو کلینیکش را ببیند، با تأیید عضویت:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Doctor/Controller/DoctorController.php
|
||||||
|
#[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_CLINIC')]
|
||||||
|
public function showForClinic(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$clinic = $this->clinicRepo->findByUser($user);
|
||||||
|
if ($clinic === null) {
|
||||||
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||||
|
if ($doctor === null) {
|
||||||
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// فقط پزشکانی که عضو این کلینیک هستند قابل مشاهدهاند
|
||||||
|
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$clinicData = [[
|
||||||
|
'id' => (string) $clinic->getId(),
|
||||||
|
'uuid' => $clinic->getUuid(),
|
||||||
|
'name' => $clinic->getName(),
|
||||||
|
'address' => $clinic->getAddress(),
|
||||||
|
'telephone' => $clinic->getTelephone(),
|
||||||
|
]];
|
||||||
|
|
||||||
|
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**وابستگیهای constructor**: `ClinicRepository $clinicRepo` باید به `DoctorController` اضافه شود (بررسی کن آیا از قبل موجود است).
|
||||||
|
|
||||||
|
### ۳. Frontend — DoctorDetailPage: fetch با endpoint مناسب
|
||||||
|
|
||||||
|
در `DoctorDetailPage.tsx`، وقتی `primaryRole === 'clinic'`، از endpoint جدید استفاده کن:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// در DoctorDetailPage.tsx — در useQuery که doctor را fetch میکند
|
||||||
|
const { data: doctorData } = useQuery({
|
||||||
|
queryKey: ['doctor', uuid, primaryRole],
|
||||||
|
queryFn: () =>
|
||||||
|
primaryRole === 'clinic'
|
||||||
|
? api.get(`/api/v1/clinic/my-doctor/${uuid}`)
|
||||||
|
: api.get(`/api/v1/doctor/${uuid}`),
|
||||||
|
enabled: !!uuid,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. Frontend — DoctorDetailPage: محدود کردن قابلیتهای ویرایش برای clinic
|
||||||
|
|
||||||
|
وقتی `primaryRole === 'clinic'`:
|
||||||
|
- دکمه ویرایش پروفایل نمایش داده **نشود** (فقط مشاهده)
|
||||||
|
- دکمه حذف نمایش داده **نشود**
|
||||||
|
- بخش دعوتنامهها (`ClinicInvitationsSection`) نمایش داده **نشود**
|
||||||
|
|
||||||
|
بررسی کن چه بخشهایی با `isOwnProfile` یا `primaryRole === 'admin'` guard شدهاند و `clinic` را به عنوان read-only viewer handle کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- `ClinicRepository::findByUser(User $user)` وجود دارد — از آن برای پیدا کردن کلینیک صاحب استفاده کن
|
||||||
|
- `Clinic::getDoctors()` یک `Collection` است — از `->contains($doctor)` برای چک عضویت استفاده کن
|
||||||
|
- `GET /api/v1/doctor/{uuid}` را تغییر نده — این endpoint عمومی است و frontend برای admin/doctor از آن استفاده میکند
|
||||||
|
- migration لازم نیست — هیچ entity تغییر نمیکند
|
||||||
|
- بعد از تغییر route باید `ddev exec php bin/console cache:clear` اجرا شود
|
||||||
|
- مستندات: `docs/api/doctor.md` را با endpoint جدید بهروزرسانی کن
|
||||||
@@ -138,7 +138,7 @@ export default function App() {
|
|||||||
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
|
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
|
||||||
<Route path="doctors" element={<RoleRoute roles={['admin']}><DoctorsPage /></RoleRoute>} />
|
<Route path="doctors" element={<RoleRoute roles={['admin']}><DoctorsPage /></RoleRoute>} />
|
||||||
<Route path="doctors/new" element={<RoleRoute roles={['admin']}><DoctorFormPage /></RoleRoute>} />
|
<Route path="doctors/new" element={<RoleRoute roles={['admin']}><DoctorFormPage /></RoleRoute>} />
|
||||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} />
|
||||||
<Route path="profile" element={<RoleRoute roles={['doctor']}><DoctorProfilePage /></RoleRoute>} />
|
<Route path="profile" element={<RoleRoute roles={['doctor']}><DoctorProfilePage /></RoleRoute>} />
|
||||||
|
|
||||||
{/* دکتر / منشی / کلینیک */}
|
{/* دکتر / منشی / کلینیک */}
|
||||||
|
|||||||
@@ -358,24 +358,31 @@ async function uploadDoctorImage(file: File): Promise<ImageFileData> {
|
|||||||
|
|
||||||
function DoctorAvatar({ name, img, idx, onUpload, uploading }: {
|
function DoctorAvatar({ name, img, idx, onUpload, uploading }: {
|
||||||
name: string; img: string | null; idx: number;
|
name: string; img: string | null; idx: number;
|
||||||
onUpload: (f: File) => void; uploading: boolean;
|
onUpload?: (f: File) => void; uploading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr';
|
const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr';
|
||||||
return (
|
return (
|
||||||
<div className="relative shrink-0 group cursor-pointer" onClick={() => ref.current?.click()}>
|
<div
|
||||||
|
className={`relative shrink-0 group ${onUpload ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => onUpload && ref.current?.click()}
|
||||||
|
>
|
||||||
{img
|
{img
|
||||||
? <img src={img} alt={name} className="w-24 h-24 rounded-2xl object-cover shadow-xl ring-4 ring-white dark:ring-gray-900" />
|
? <img src={img} alt={name} className="w-24 h-24 rounded-2xl object-cover shadow-xl ring-4 ring-white dark:ring-gray-900" />
|
||||||
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{initials}</div>
|
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{initials}</div>
|
||||||
}
|
}
|
||||||
<div className="absolute inset-0 rounded-2xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
{onUpload && (
|
||||||
{uploading
|
<div className="absolute inset-0 rounded-2xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
? <span className="w-6 h-6 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
{uploading
|
||||||
: <CameraIcon className="w-7 h-7 text-white" />
|
? <span className="w-6 h-6 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||||
}
|
: <CameraIcon className="w-7 h-7 text-white" />
|
||||||
</div>
|
}
|
||||||
<input ref={ref} type="file" accept="image/*" className="hidden"
|
</div>
|
||||||
onChange={e => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} />
|
)}
|
||||||
|
{onUpload && (
|
||||||
|
<input ref={ref} type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={e => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2148,8 +2155,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
|||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ['doctor-detail', uuid],
|
queryKey: ['doctor-detail', uuid, primaryRole],
|
||||||
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
|
queryFn: () =>
|
||||||
|
primaryRole === 'clinic'
|
||||||
|
? api.get<ApiResponse<any>>(`/api/v1/clinic/my-doctor/${uuid}`)
|
||||||
|
: api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
|
||||||
enabled: !!uuid,
|
enabled: !!uuid,
|
||||||
});
|
});
|
||||||
const specialtiesQ = useQuery({
|
const specialtiesQ = useQuery({
|
||||||
@@ -2332,9 +2342,9 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => navigate('/admin/doctors')}
|
<button onClick={() => navigate(primaryRole === 'clinic' ? '/admin/dashboard' : '/admin/doctors')}
|
||||||
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
|
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
|
||||||
<ArrowRightIcon className="w-4 h-4" />پزشکان
|
<ArrowRightIcon className="w-4 h-4" />{primaryRole === 'clinic' ? 'داشبورد' : 'پزشکان'}
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل پزشک</span>
|
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل پزشک</span>
|
||||||
@@ -2349,8 +2359,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
|||||||
|
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
|
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
|
||||||
onUpload={handleImageUpload} uploading={uploadingImg} />
|
onUpload={primaryRole !== 'clinic' ? handleImageUpload : undefined} uploading={uploadingImg} />
|
||||||
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
|
{primaryRole !== 'clinic' && (
|
||||||
|
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 sm:mb-1">
|
<div className="flex-1 min-w-0 sm:mb-1">
|
||||||
@@ -2389,12 +2401,14 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:mb-1 flex-wrap">
|
<div className="flex items-center gap-2 sm:mb-1 flex-wrap">
|
||||||
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
|
{primaryRole !== 'clinic' && (
|
||||||
<PencilIcon className="w-4 h-4" />ویرایش
|
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
|
||||||
</button>
|
<PencilIcon className="w-4 h-4" />ویرایش
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toggle active / Delete — فقط ادمین */}
|
{/* Toggle active / Delete — فقط ادمین */}
|
||||||
{!isOwnProfile && (
|
{!isOwnProfile && primaryRole !== 'clinic' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setToggleConfirm(true)}
|
onClick={() => setToggleConfirm(true)}
|
||||||
|
|||||||
@@ -106,6 +106,44 @@ Get doctor detail with clinics.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET `/api/v1/clinic/my-doctor/{doctorUuid}`
|
||||||
|
|
||||||
|
Get doctor detail for clinic owner — only doctors who are members of the authenticated clinic.
|
||||||
|
|
||||||
|
**Permission:** `ROLE_CLINIC`
|
||||||
|
|
||||||
|
### Path Parameters
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `doctorUuid` | string (UUID) | Doctor UUID |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"title": "دکتر علی احمدی",
|
||||||
|
"specialties": [...],
|
||||||
|
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
||||||
|
|
||||||
|
> **Side note:** Returns only the authenticated clinic's data in the `clinics` array (not all clinics of the doctor).
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ERR_VALIDATION_002` | 404 | Clinic or doctor not found |
|
||||||
|
| `ERR_AUTH_006` | 403 | Doctor is not a member of this clinic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET `/api/v1/doctors`
|
## GET `/api/v1/doctors`
|
||||||
|
|
||||||
List doctors with pagination and filters.
|
List doctors with pagination and filters.
|
||||||
|
|||||||
@@ -163,6 +163,39 @@ class DoctorController extends BaseController
|
|||||||
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]);
|
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_CLINIC')]
|
||||||
|
public function showForClinic(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$clinic = $this->clinicRepo->findByUser($user);
|
||||||
|
if ($clinic === null) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||||
|
if ($doctor === null) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پزشک یافت نشد', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||||
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => [[
|
||||||
|
'id' => (string) $clinic->getId(),
|
||||||
|
'uuid' => $clinic->getUuid(),
|
||||||
|
'name' => $clinic->getName(),
|
||||||
|
'address' => $clinic->getAddress(),
|
||||||
|
'telephone' => $clinic->getTelephone(),
|
||||||
|
'city_id' => $clinic->getCityId(),
|
||||||
|
'province_id' => $clinic->getProvinceId(),
|
||||||
|
'map' => [
|
||||||
|
'latitude' => $clinic->getLatitude() !== null ? (string) $clinic->getLatitude() : null,
|
||||||
|
'longitude' => $clinic->getLongitude() !== null ? (string) $clinic->getLongitude() : null,
|
||||||
|
],
|
||||||
|
]]])]);
|
||||||
|
}
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
path: '/api/v1/doctors',
|
path: '/api/v1/doctors',
|
||||||
summary: 'List doctors with optional filters (paginated)',
|
summary: 'List doctors with optional filters (paginated)',
|
||||||
|
|||||||
Reference in New Issue
Block a user