diff --git a/.claude/prompt/fix-guest-doctor-clinic-context-access.md b/.claude/prompt/fix-guest-doctor-clinic-context-access.md new file mode 100644 index 00000000..290bbd0a --- /dev/null +++ b/.claude/prompt/fix-guest-doctor-clinic-context-access.md @@ -0,0 +1,115 @@ +# رفع باگ دسترسی کاملِ پزشکِ مهمان در محیط کلینیک + +## پروژه + +`clinicpro` (Admin React SPA + گارد Backend). ادامه/تکمیلِ پرامپت قبلی `fix-access-scoping-doctor-clinic-representation.md`. + +## زمینه + +در پرامپت قبلی، نقشِ context پزشکی که به کلینیک دعوت می‌شود از `clinic` به `doctor` + `scope: clinic` تغییر کرد (در `buildAvailableContexts`). این درست بود ولی **ناقص**: هیچ‌جای فرانت و بک‌اند به فیلد `scope` توجه نمی‌کند. در نتیجه وقتی پزشکِ مهمان به محیط کاریِ آن کلینیک سوییچ می‌کند (`db_uuid = clinicUuid`)، همان منوی کاملِ نقش `doctor` را می‌بیند — شامل **پرسنل، منشی‌ها، سرویس‌ها، اشتراک، پرونده بیماران** — و عملاً به امکانات مدیریتیِ محیط کلینیک دسترسی دارد. این یعنی باگ «پزشکِ اضافه‌شده به کلینیک، دسترسی به همه‌ی امکانات کلینیک دارد» هنوز باقی است. + +رفتار درست: پزشکِ مهمان در محیط کلینیک فقط باید **نوبت‌های خودش در آن کلینیک** را ببیند (read/منظوره‌ی محدود)، نه ابزارهای مدیریت کلینیک. در محیط «مطب شخصی» خودش (context با `scope` خالی) همه‌ی ابزارها در دسترس می‌ماند. + +## مشکل / هدف + +`scope: 'clinic'` که روی context پزشکِ مهمان ست شده، باید واقعاً دسترسی را محدود کند — هم در منوی Sidebar، هم در RoleRouteهای SPA، و هم با گارد در endpointهای مدیریتی (defense in depth). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Auth/Controller/AuthController.php` | `buildAvailableContexts` (از قبل `scope: 'clinic'` می‌دهد) و `userinfo`/`switchContext` که `context` را برمی‌گرداند | +| `assets/admin/stores/authStore.ts` | نگه‌داری `context` فعال + `primaryRole`؛ باید `scope` در دسترس باشد | +| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش `doctor` — باید در `scope: clinic` محدود شود | +| `assets/admin/App.tsx` | `RoleRoute` — صفحات مدیریتیِ پزشک باید در `scope: clinic` مسدود شوند | +| `src/Staff/Controller/StaffController.php` | `resolveEntity` پزشک = scope شخصی؛ گارد لازم برای حالت مهمان | +| `src/Secretary/Controller/SecretaryController.php` | همان منطق برای منشی‌ها | +| `src/ClinicService/Controller/*` (سرویس‌ها) | همان منطق | +| `docs/api/auth.md` | مستندسازی معنای `scope` | + +## وضعیت فعلی (کد واقعی) + +`buildAvailableContexts` — context مهمان دارای `scope` ولی بدون اثر: +```php +foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + $isOwner = $clinic->getUser()->getId() === $user->getId(); + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => $isOwner ? 'clinic' : 'doctor', + 'scope' => $isOwner ? null : 'clinic', // ← ست می‌شود ولی هیچ‌جا مصرف نمی‌شود + 'doctor_uuid' => $doctor->getUuid(), + ]; +} +``` + +`Sidebar.tsx` — منوی پزشک، بدون توجه به scope (همه‌ی آیتم‌های مدیریتی نمایش داده می‌شوند): +```tsx +if (primaryRole === "doctor") { + return [ + { label: "عمومی", items: [{ to: "/admin/dashboard", ... }] }, + { label: "پروفایل", items: [{ to: "/admin/profile", ... }] }, + { label: "مدیریت", items: [ + { to: "/admin/appointments", label: "نوبت‌های من" }, + { to: "/admin/my-patients", label: "پرونده بیماران" }, + { to: "/admin/staff", label: "پرسنل" }, // ← نباید برای مهمان باشد + { to: "/admin/my-secretaries", label: "منشی ها" }, // ← نباید + { to: "/admin/clinic-services",label: "سرویس‌ها" }, // ← نباید + ... + ]}, + ]; +} +``` + +`authStore.ts` — context فعال نگه‌داری می‌شود (شامل `role` و باید `scope`): +```ts +primaryRole: res.data.context?.role ?? null, +context: res.data.context, +``` +(نوع `ContextItem` احتمالاً `scope?` ندارد — باید اضافه شود.) + +## وظایف + +### ۱. در دسترس‌بودن `scope` در authStore + +- در `assets/admin/stores/authStore.ts`، type `ContextItem` فیلد `scope?: string | null` را داشته باشد و از `userinfo`/`switchContext` پر شود. +- یک selector/مقدار مشتق در store یا کامپوننت‌ها: `isClinicScopedDoctor = primaryRole === 'doctor' && context?.scope === 'clinic'`. + +### ۲. محدودکردن منوی Sidebar برای پزشکِ مهمان + +در `Sidebar.tsx`، شاخه‌ی `primaryRole === "doctor"`: +- اگر `context?.scope === 'clinic'` بود، فقط آیتم‌های مجاز را برگردان: + - داشبورد (`/admin/dashboard`) + - نوبت‌ها (`/admin/appointments`) — نوبت‌های پزشک در آن کلینیک +- آیتم‌های `staff`, `my-secretaries`, `clinic-services`, `subscription`, `my-patients`, `profile` (مدیریت/داده‌ی شخصیِ مطب) را در این حالت **نشان نده**. +- در محیط «مطب شخصی» (`scope` خالی) منوی کامل فعلی بماند. + +### ۳. گارد RoleRoute در SPA برای صفحات مدیریتیِ مهمان + +در `App.tsx`: +- یک گارد سبک اضافه کن (مثلاً گسترش `RoleRoute` یا یک wrapper `ClinicScopeBlock`) که اگر کاربر `doctor` با `context.scope === 'clinic'` است، مسیرهای `staff`, `my-secretaries`, `clinic-services`, `subscription`, `my-patients`, `profile` را به `/admin/dashboard` ریدایرکت کند. +- مسیرهای `dashboard` و `appointments` باز بمانند. + +### ۴. گارد Backend (defense in depth) + +حتی با محدودیت UI، endpointهای مدیریتی نباید به پزشکِ مهمان اجازه‌ی عمل روی کلینیک بدهند. الگوی موجود این است که `StaffController::resolveEntity` برای `ROLE_DOCTOR` همیشه scope **شخصیِ** پزشک را برمی‌گرداند (`['doctor', doctorId]`) — یعنی پزشک فقط پرسنل/سرویسِ مطب خودش را می‌بیند، نه کلینیک. این را **تأیید کن** و مطمئن شو هیچ endpoint مدیریتی، `db_uuid`/clinicUuid را از کلاینت گرفته و بدون چک مالکیت (`clinic.getUser() === currentUser`) روی کلینیک عمل نمی‌کند: +- `StaffController`, `SecretaryController`, `ClinicService` controllers, و `ClinicController` (update/address/logo) را مرور کن؛ هر مسیری که کلینیک را با `findByUuid($clinicUuid)` می‌گیرد باید مالکیت یا `ROLE_ADMIN` را چک کند (الگوی `$clinic->getUser()->getId() !== $user->getId() && !hasRole('ROLE_ADMIN')` → 403). اگر جایی این چک نیست، اضافه کن. +- نتیجه‌ی مورد انتظار: پزشکِ مهمان (نه مالک، نه ادمین) روی این endpointها `403` بگیرد. + +### ۵. مستندسازی + +- `docs/api/auth.md`: در توضیح `context`/`available_contexts`، معنای `scope: 'clinic'` را شرح بده: «پزشکِ مهمان در محیط کلینیک؛ فقط نوبت‌های خودش، بدون دسترسی مدیریتی به کلینیک». + +## نکات مهم + +- **منبع حقیقتِ scope = context فعال**، نه `dbUuid` تنها. تمایز «مالک کلینیک» (`role: clinic`)، «پزشک در مطب شخصی» (`role: doctor`, بدون scope)، و «پزشک مهمان در کلینیک» (`role: doctor`, `scope: clinic`). +- در محیط مطب شخصی هیچ محدودیتی اضافه نشود؛ فقط `scope: clinic` محدود می‌شود. +- گارد UI برای UX است؛ **گارد واقعی Backend است** — هر دو لازم‌اند (وظیفه ۴ نباید رها شود). +- ادمین (`ROLE_ADMIN`) همیشه دسترسی کامل دارد؛ مالک کلینیک (`role: clinic`) هم بدون تغییر. +- بعد از تغییر backend، `cache:clear` و به‌روزرسانی `docs/api/auth.md`. تغییر فرانت با `tsc --noEmit` + `yarn dev` تست شود. +- **تست‌ها** (با `lexik:jwt:generate-token`): + - پزشکِ مهمان → `userinfo` آن کلینیک `role:doctor, scope:clinic`؛ بعد از switch، Sidebar فقط «داشبورد» و «نوبت‌ها»؛ مسیرهای `/admin/staff` و `/admin/clinic-services` ریدایرکت به داشبورد. + - همان پزشک روی endpoint مدیریتی کلینیک (مثلاً ساخت پرسنل برای آن کلینیک) → `403`. + - مالک واقعی کلینیک → دسترسی کامل بدون تغییر. + - پزشک در مطب شخصی → منوی کامل و دسترسی به staff/services خودش بدون تغییر. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 5880b3dc..ed7cc47c 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -87,10 +87,15 @@ function PublicRoute({ children }: { children: React.ReactNode }) { return isAuthenticated ? : <>{children}; } -function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) { +function RoleRoute({ roles, blockClinicScope, children }: { roles: string[]; blockClinicScope?: boolean; children: React.ReactNode }) { const primaryRole = useAuthStore((s) => s.primaryRole); + const context = useAuthStore((s) => s.context); if (!primaryRole) return
در حال بارگذاری...
; if (!roles.includes(primaryRole)) return ; + // پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد. + if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') { + return ; + } return <>{children}; } @@ -159,18 +164,18 @@ export default function App() { } /> } /> } /> - } /> + } /> {/* دکتر / منشی / کلینیک */} - } /> + } /> } /> {/* فاز ۲ — دکتر / کلینیک */} - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> {/* فقط ادمین */} diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index ed2657c8..52aadb71 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -40,7 +40,22 @@ type Section = { label: string; items: SectionItem[] }; function buildSections( primaryRole: string | null, dbUuid: string | null, + scope: string | null, ): Section[] { + // پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبت‌های خودش؛ + // ابزارهای مدیریتی مطب/کلینیک نمایش داده نمی‌شوند. + if (primaryRole === "doctor" && scope === "clinic") { + return [ + { + label: "عمومی", + items: [ + { to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }, + { to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبت‌های من" }, + ], + }, + ]; + } + if (primaryRole === "admin") { return [ { @@ -366,12 +381,12 @@ interface Props { export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { const sidebarOpen = useUiStore((s) => s.sidebarOpen); - const { logout, primaryRole, userName, availableContexts, dbUuid } = + const { logout, primaryRole, userName, availableContexts, dbUuid, context } = useAuthStore(); const { hasFeature } = useSubscription(); const navigate = useNavigate(); - const sections = buildSections(primaryRole, dbUuid); + const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null); const initials = (userName ?? "U").charAt(0).toUpperCase(); return ( diff --git a/assets/admin/stores/authStore.ts b/assets/admin/stores/authStore.ts index 611113b1..faed650c 100644 --- a/assets/admin/stores/authStore.ts +++ b/assets/admin/stores/authStore.ts @@ -6,6 +6,7 @@ export interface ContextItem { db_uuid: string; name: string; role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user'; + scope?: string | null; doctor_uuid?: string; permissions?: Record; } diff --git a/docs/api/auth.md b/docs/api/auth.md index 4ed25cb6..8519ed3b 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -297,7 +297,7 @@ Authorization: Bearer - context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک) - context منشی: `"secretary"` -> **نکته frontend:** پس از `switchContext`، `primaryRole` در store باید از `context.role` آپدیت شود. +> **نکته frontend:** پس از `switchContext`، `primaryRole` در store از `context.role` و `scope` از `context.scope` آپدیت می‌شود. وقتی `role:"doctor"` و `scope:"clinic"` است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبت‌ها» را نشان می‌دهد و مسیرهای مدیریتی (`staff`, `clinic-services`, `subscription`, `my-secretaries`, `my-patients`, `profile`) به داشبورد ریدایرکت می‌شوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope **شخصیِ** خودش را برمی‌گردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک می‌کنند (۴۰۳). **قانون `db_uuid`**: - اگر یک context وجود دارد: خودکار فعال می‌شود