From 9d46577181c56b3b85813ee92c46eeb47cfb4fe5 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 17:10:54 +0330 Subject: [PATCH] feat(secretary): add `services` permission resource + panel gating (phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secretaries could reach neither the services module (EntityContextResolver does not recognise a secretary as clinic owner, so they resolved to `unknown` → 403) nor had any toggle to grant it. Add `services` as a first-class secretary permission resource, enforced end-to-end. Backend - DoctorSecretary::DEFAULT_PERMISSIONS: new `services` resource (default-deny). - SecretaryAccessChecker::resolveOwnerEntity(): reusable owner (clinic/doctor) resolution from the secretary's active context, for controllers whose data is fetched by [entityType, entityId] and whose generic resolver is not secretary-aware. - ClinicServiceController: resolveEntity() is now secretary-aware; every action (sections, items, tariffs — 13 total) guards with `services` view/create/ update/delete via denyUnlessGranted, ahead of the subscription gate. Frontend - SecretaryPermissions type + MySecretariesPage + SecretariesPage: `services` section so owners can grant it. - Sidebar (secretary branch): services / inventory / tags menu items gated by can(resource, 'view'). - RoleRoute: a secretary now needs the page's `permission` to open it (direct URL entry included); clinic-services, inventory, tags-settings routes accept secretary + permission gate. Tests - SecretaryResourceEnforcementTest: services denied-by-default, allowed-when- granted, create-denied-while-view-granted. - Sidebar.test: secretary menu gating for services/inventory/tags. Docs: secretary.md + clinic-services.md updated with the `services` resource and the resolveOwnerEntity note. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...retary-permissions-full-coverage-phase2.md | 232 ++++++++++++++++++ assets/admin/App.tsx | 18 +- .../admin/components/layout/Sidebar.test.tsx | 41 ++++ assets/admin/components/layout/Sidebar.tsx | 21 ++ assets/admin/pages/MySecretariesPage.tsx | 11 + assets/admin/pages/SecretariesPage.tsx | 10 + assets/admin/types/index.ts | 6 + docs/api/clinic-services.md | 2 + docs/api/secretary.md | 9 +- .../Controller/ClinicServiceController.php | 21 ++ src/Secretary/Entity/DoctorSecretary.php | 1 + .../Security/SecretaryAccessChecker.php | 28 +++ .../SecretaryResourceEnforcementTest.php | 37 +++ 13 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 .claude/prompt/secretary-permissions-full-coverage-phase2.md diff --git a/.claude/prompt/secretary-permissions-full-coverage-phase2.md b/.claude/prompt/secretary-permissions-full-coverage-phase2.md new file mode 100644 index 00000000..4f4a0a26 --- /dev/null +++ b/.claude/prompt/secretary-permissions-full-coverage-phase2.md @@ -0,0 +1,232 @@ +# Secretary Permissions — Full Coverage & Panel Enforcement (Phase 2 / completion) + +## پروژه + +`clinicpro` (backend Symfony + پنل ادمین React). تک‌پروژه، cross-repo نیست. + +> پیش از هر گرِپ/خواندن، طبق قانون پروژه اول `graphify query "..."` بزن. بعد از هر تغییر کد (پس از commit): `graphify update .`. + +## زمینه — چه چیزی از قبل انجام شده (تأییدشده در کد) + +فاز اول ([secretary-permissions-coverage-and-panel-enforcement.md](secretary-permissions-coverage-and-panel-enforcement.md)) بخش زیادی را ساخته است. **دوباره نساز، فقط شکاف‌ها را کامل کن.** + +آنچه **قبلاً هست**: + +- **مدل مجوز:** `DoctorSecretary::DEFAULT_PERMISSIONS['resources']` با ۸ منبع: + `appointments, patients, payments, insurances, addresses, clinic_info, inventory, tags` + (`src/Secretary/Entity/DoctorSecretary.php:20`). +- **Enforcement مشترک:** `src/Secretary/Security/SecretaryAccessChecker.php` — نقطهٔ واحد + (`can()`, `canOrNonSecretary()`, `denyUnlessGranted()`). صدا زده می‌شود در: + `PaymentController`, `PatientController`, `InventoryController`, `TenantTagController`, + `InsuranceController`, `PaymentMethodController`, و `AppointmentAccessChecker`. +- **اسکوپ per-doctor نوبت‌ها:** `MyAppointmentsController::resolveSecretaryFilter()` (L316) لیست را + به `doctorIds` مجاز (ردیف‌های `DoctorSecretary`) محدود می‌کند؛ `AppointmentRepository::findByUserAndDoctorIds()` + و `PatientRecordScopeResolver::forSecretary()` (L87) هم بر همان مجموعه پزشکان فیلتر می‌کنند. +- **Frontend gating primitive:** `assets/admin/hooks/usePermissions.ts` → `can(resource, action)` + (خالی‌بودن `permissions` = محیط شخصی، آزاد). Sidebar از آن استفاده می‌کند. +- **UI مجوزها:** `assets/admin/pages/MySecretariesPage.tsx` → `PERMISSION_SECTIONS` + `EMPTY_PERMISSIONS` + همان ۸ منبع را نمایش می‌دهد. `SecretaryPermissions` type در `assets/admin/types/index.ts:458`. + +## مشکل / هدف — شکاف‌های باقی‌مانده + +سه شکاف مشخص، مطابق پلن ۱۰-پرامپتی کاربر: + +1. **منابع مجوزِ جانداده‌شده (Coverage gap).** این صفحات/ماژول‌ها منشی به آن‌ها دسترسی دارد یا در + منوی تنظیمات دیده می‌شوند ولی **هیچ toggle مجوز ندارند** و enforce نمی‌شوند: + `services` (سرویس‌ها)، `discounts` (تخفیف‌ها)، `sms` (پیامک‌ها)، `staff` (پرسنل)، + `clinic_doctors` (مدیریت پزشکان کلینیک — فقط حالت کلینیک)، `appointment_settings` (تنظیمات نوبت‌دهی)، + `subscription` (خرید اشتراک/پرداخت‌ها). +2. **اعمال ناقص در Frontend (Panel gap).** `usePermissions().can` فقط برای + `appointments/patients/payments/insurances` در Sidebar استفاده شده + (`assets/admin/components/layout/Sidebar.tsx:365-388`). `inventory`, `tags` و منابع جدید + نه در Sidebar گِیت می‌شوند، نه Route آن‌ها در `App.tsx` گارد دارد، نه دکمه‌های CRUD صفحه پنهان می‌شوند. +3. **حساب کاربری (Account) نباید هیچ مجوزی داشته باشد** — باید همیشه برای منشی باز باشد؛ مطمئن شو + هیچ گارد اشتباهی رویش نیست (Prompt 3). + +هدف نهایی: مالک (پزشک مستقل یا کلینیک) دقیقاً تعیین کند هر منشی به کدام صفحه/عملیات دسترسی دارد، +و منشی فقط همان‌ها را در Sidebar/Route/دکمه‌ها ببیند و API هم خارج از مجوز ۴۰۳ بدهد. تست برای **دو حالت: +پزشک مستقل** و **کلینیک چند-پزشکه**. + +## فایل‌های مرتبط + +| فایل | نقش | کار لازم | +|------|-----|----------| +| `src/Secretary/Entity/DoctorSecretary.php` | `DEFAULT_PERMISSIONS` | افزودن منابع جدید | +| `src/Secretary/Security/SecretaryAccessChecker.php` | نقطهٔ واحد enforcement | بدون تغییر ساختار؛ فقط صدا زدن در کنترلرهای جدید | +| `src/DoctorService/Controller/DoctorServiceController.php` · `src/ClinicService/Controller/ClinicServiceController.php` | سرویس‌ها | گارد `services` | +| `src/Discount/Controller/DiscountController.php` | تخفیف‌ها | گارد `discounts` | +| `src/Sms/Controller/{SmsController,SmsMessageController,SmsWalletController}.php` | پیامک‌ها | گارد `sms` | +| `src/Staff/Controller/StaffController.php` | پرسنل | گارد `staff` | +| `src/Clinic/Controller/ClinicController.php` (بخش پزشکان کلینیک) | مدیریت پزشکان کلینیک | گارد `clinic_doctors` — فقط clinic | +| `src/Appointment/Controller/AppointmentSettingsController.php` | تنظیمات نوبت‌دهی | گارد `appointment_settings` | +| `src/Subscription/Controller/SubscriptionController.php` | اشتراک | گارد `subscription` (یا حذف `ROLE_SECRETARY` اگر نباید ببیند) | +| `assets/admin/pages/MySecretariesPage.tsx` | فرم مجوز | افزودن `PERMISSION_SECTIONS` + `EMPTY_PERMISSIONS` منابع جدید | +| `assets/admin/types/index.ts` (`SecretaryPermissions` L458) | type | افزودن کلیدهای جدید | +| `assets/admin/hooks/usePermissions.ts` | `can()` | بدون تغییر — استفاده گسترده‌تر | +| `assets/admin/components/layout/Sidebar.tsx` (بخش `primaryRole === "secretary"` L361) | منوی منشی | گِیت هر آیتم با `can(resource,'view')` | +| `assets/admin/App.tsx` | جدول Route | گارد Route هر صفحهٔ منشی | +| صفحات پنل هر منبع (Services/Discounts/Sms/Staff/…Page.tsx) | دکمه‌های CRUD | پنهان‌کردن دکمه create/edit/delete با `can()` | + +## وضعیت فعلی (کد واقعی) + +منبع حقیقت مجوز: + +```php +// src/Secretary/Entity/DoctorSecretary.php:20 +public const DEFAULT_PERMISSIONS = [ + 'version' => 1, + 'resources' => [ + 'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true], + 'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'clinic_info' => ['view' => true, 'update' => false], + 'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], + 'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], + // ← منابع جدید اینجا اضافه می‌شوند + ], +]; +``` + +نقطهٔ واحد enforcement (از این الگو در کنترلرهای جدید استفاده کن — چیز جدید نساز): + +```php +// src/Secretary/Security/SecretaryAccessChecker.php:73 +public function denyUnlessGranted(User $user, string $resource, string $action): void +{ + if (!$this->canOrNonSecretary($user, $resource, $action)) { + // → ۴۰۳ استاندارد + } +} +``` + +Frontend فقط ۴ منبع را در Sidebar گِیت کرده: + +```tsx +// assets/admin/components/layout/Sidebar.tsx (primaryRole === "secretary") +if (can("appointments", "view")) { ... } +if (can("patients", "view")) { ... } +if (can("payments", "view")) { ... } +if (can("insurances", "view")) { ... } +// ← inventory / tags / services / discounts / sms / staff / clinic_doctors / appointment_settings گِیت نشده‌اند +``` + +## وظایف + +> قانون هر منبع: **همزمان در هر ۳ جا** اضافه شود وگرنه merge/نمایش می‌شکند — +> (۱) `DoctorSecretary::DEFAULT_PERMISSIONS`، (۲) `MySecretariesPage.tsx` (`EMPTY_PERMISSIONS` + `PERMISSION_SECTIONS`)، +> (۳) `SecretaryPermissions` type. سپس backend enforce + frontend gate. +> بعد از هر تغییر endpoint: `docs/api/*` همان session به‌روز شود. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست. + +### ۰. ممیزی و تصمیم (اول این) + +1. `grep -rln ROLE_SECRETARY src/` را با فهرست صفحاتی که منشی در Sidebar/پنل می‌بیند تطبیق بده. + جدول بساز: **منبع | کنترلر(ها) | toggle دارد؟ | backend enforce؟ | sidebar gate؟ | route guard؟ | CRUD gate؟**. +2. برای هر ماژول تصمیم بگیر: **الف)** منشی می‌تواند دسترسی داشته باشد → toggle اضافه کن؛ + **ب)** هرگز نباید ببیند → `ROLE_SECRETARY` را از کنترلر بردار و در پرامپت دلیل را بنویس. (مثلاً `subscription` + احتمالاً باید فقط برای owner باشد؛ اگر منشی نباید بخرد، به‌جای toggle نقش را حذف کن.) + +### ۱. افزودن منابع مجوز جدید (Prompt 2 + 4 + 9) + +برای هر منبعِ زیر که در ممیزی «باید toggle داشته باشد»، در هر ۳ جا اضافه کن: + +```php +// DoctorSecretary::DEFAULT_PERMISSIONS['resources'] — همه پیش‌فرض false (اصل least-privilege) +'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], +'discounts' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], +'sms' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], +'staff' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], +'appointment_settings' => ['view' => false, 'update' => false], +'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], // فقط clinic +``` + +- `mergePermissions` عمیق merge می‌کند؛ منشی‌های موجود نمی‌شکنند. اما یک data migration اختیاری برای + backfill کلیدهای جدید روی ردیف‌های قدیمی در نظر بگیر (یا در `getPermissions()` با `DEFAULT_PERMISSIONS` ادغام کن). +- `clinic_doctors` فقط در حالت `OWNER_CLINIC` معنا دارد: در `MySecretariesPage.tsx` این section را + فقط وقتی نمایش بده که owner کلینیک است (پزشک مستقل نباید این toggle را ببیند — Prompt 9). + +### ۲. Enforcement در Backend (Prompt 6) + +در هر کنترلر جدید، **در ابتدای هر اکشن** با نقطهٔ واحد گارد بگذار (چیز جدید نساز): + +```php +$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); // GET +$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create'); // POST +$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); // PUT/PATCH +$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete'); // DELETE +``` + +- `canOrNonSecretary` تضمین می‌کند برای owner/پزشک/ادمین رفتار تغییر نکند (فقط منشی محدود شود). +- نبود مجوز → همان ۴۰۳ استاندارد `AccessChecker` (نه ۲۰۰ با لیست خالی، نه ۵۰۰). +- کنترلرهای: `DoctorServiceController`/`ClinicServiceController` (`services`)، `DiscountController` (`discounts`)، + `Sms*Controller` (`sms`)، `StaffController` (`staff`)، `AppointmentSettingsController` (`appointment_settings`)، + بخش پزشکانِ `ClinicController` (`clinic_doctors`). + +### ۳. Enforcement در Frontend — Sidebar + Route + CRUD (Prompt 5 + 7) + +1. **Sidebar** (`Sidebar.tsx`، بخش `secretary`): هر آیتم منو را با `can(resource,'view')` بپیچ — + شامل `inventory`, `tags` و همهٔ منابع جدید. منشی نباید هیچ منوی بدون‌مجوز ببیند و نباید هیچ منوی اضافی بماند. +2. **Route guard** (`App.tsx`): هر Route صفحهٔ منشی را گارد کن؛ ورود مستقیم با URL بدون مجوز → + redirect یا صفحهٔ «دسترسی ندارید» فارسی استاندارد (نه صفحهٔ سفید). اگر helper مشترکی نیست، + یک `` سبک بساز که از `usePermissions().can` استفاده کند + (از `FeatureGate` موجود الگو بگیر). +3. **CRUD/Action buttons** در هر صفحه: دکمه‌های create/edit/delete/import/export و + FloatingAction/ContextMenu/QuickAction را با `can(resource, action)` شرطی کن. نبودِ مجوز = پنهان، نه disable. +4. Breadcrumb صفحهٔ بدون‌مجوز هم نباید ساخته شود. + +### ۴. حساب کاربری بدون مجوز (Prompt 3) + +- صفحهٔ Account/پروفایل کاربری باید برای منشی همیشه باز باشد. بررسی کن هیچ `ROLE_*` سخت‌گیرانه یا + `denyUnlessGranted` اشتباه رویش نیست و هیچ toggle برایش تعریف نشده. اگر endpoint اکانت + `IS_AUTHENTICATED_FULLY` دارد کافی است — دست نزن. + +### ۵. اسکوپ per-doctor نوبت‌ها — تکمیل پوشش عملیات (Prompt 8) + +اسکوپ لیست از قبل هست (`resolveSecretaryFilter`)؛ فقط مطمئن شو **همهٔ** عملیات نوبت هم به مجموعهٔ +پزشکان مجاز محدودند، نه فقط GET لیست: + +- create / update / cancel / update_status / قطعی‌کردن / پرداخت / فاکتور / تغییر وضعیت / مشاهدهٔ پروندهٔ بیمار. +- در هر اکشن، پیش از عمل چک کن `doctor` هدف در `allowed doctorIds` منشی باشد (از `DoctorSecretaryRepository`)؛ + در غیر این صورت ۴۰۳ — حتی اگر `appointments.=true`. (مجوز action و اسکوپ doctor **هر دو** لازم‌اند.) +- تست هر دو سناریو: پزشک مستقل (فقط نوبت‌های خودش) و کلینیک با منشیِ محدود به زیرمجموعه‌ای از پزشکان. + +### ۶. تست نهایی و ماتریس (Prompt 10) + +- Backend (`ddev exec php bin/phpunit`): برای هر منبع تازه، تست منشی مجاز (۲۰۰) و غیرمجاز (۴۰۳)، + و برای نوبت‌ها تست دسترسی به پزشک خارج از اسکوپ (۴۰۳). +- Frontend (`yarn test`): Sidebar با permissionهای مختلف فقط آیتم‌های مجاز را رندر کند؛ + دکمه‌های CRUD بدون مجوز رندر نشوند. +- دستی با منشیِ واقعی (`clinicpro-QA accounts`) در دو حالت پزشک مستقل و کلینیک. +- جدول نهایی را در خروجی بده و فقط وقتی «تمام» اعلام کن که همه PASS باشند: + +``` +Resource | Toggle | Sidebar | Route | API(403) | CRUD | Tested +appointments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS +patients | ✓ | ✓ | ✓ | ✓ | ✓ | PASS +payments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS +insurances | ✓ | ✓ | ✓ | ✓ | ✓ | PASS +addresses | ✓ | … | … | ✓ | … | ? +clinic_info | ✓ | … | … | ✓ | … | ? +inventory | ✓ | ? | ? | ✓ | ? | ? +tags | ✓ | ? | ? | ✓ | ? | ? +services | + | + | + | + | + | ? +discounts | + | + | + | + | + | ? +sms | + | + | + | + | + | ? +staff | + | + | + | + | + | ? +appointment_settings | + | + | + | + | + | ? +clinic_doctors(clinic)| + | + | + | + | + | ? +``` +(`+` = این پرامپت باید بسازد، `…`/`?` = ممیزی وظیفهٔ ۰ وضعیت واقعی را پر کند.) + +## نکات مهم + +- **منبع حقیقت = `DoctorSecretary.permissions` JSON.** enforcement جدید همین را بخواند، نه + `ClinicDoctorPermissionChecker` (که مال پزشکِ عضو کلینیک است، نه منشی). +- نقطهٔ واحد enforcement از قبل هست: `SecretaryAccessChecker`. **کنترلر/checker موازی نساز** — فقط `denyUnlessGranted` صدا بزن (اصل ۲ CLAUDE.md: اول موجود را استفاده کن). +- هر منبع همزمان در سه‌گانهٔ Entity default / UI section / TS type اضافه شود. +- `clinic_doctors` فقط حالت کلینیک؛ برای پزشک مستقل نه toggle نه منو. +- least-privilege: منابع جدید پیش‌فرض همه `false`. +- تاریخ‌ها Unix timestamp؛ پاسخ‌ها `$this->success()/$this->error()`؛ لیست‌ها array-hydration. +- رشته‌های UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفت‌وگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیاده‌سازی. +- بعد از تغییر endpointها، این فایل‌های `docs/api/` به‌روز: `secretary.md`, `service.md`/`clinic-service.md`, `discount.md`, `sms.md`, `staff.md`, `clinic.md`, `appointment.md`, `subscription.md`. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 7f702ff2..7854ad91 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -121,7 +121,11 @@ function PublicRoute({ children }: { children: React.ReactNode }) { function RoleRoute({ roles, blockClinicScope, permission, children }: { roles: string[]; blockClinicScope?: boolean; - /** [resource, action] — پزشکِ مهمان با داشتن این مجوز از blockClinicScope مستثنا می‌شود. */ + /** + * [resource, action] — گیت مجوز صفحه. + * • پزشکِ مهمان در محیط کلینیک با داشتن این مجوز از blockClinicScope مستثنا می‌شود. + * • منشی فقط با داشتن این مجوز به صفحه دسترسی دارد (هم‌راستا با enforcement سمت API). + */ permission?: [string, string]; children: React.ReactNode; }) { @@ -130,6 +134,10 @@ function RoleRoute({ roles, blockClinicScope, permission, children }: { const { can } = usePermissions(); if (!primaryRole) return
در حال بارگذاری...
; if (!roles.includes(primaryRole)) return ; + // منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند. + if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) { + return ; + } // پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد. if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') { if (!permission || !can(permission[0], permission[1])) { @@ -243,14 +251,14 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.test.tsx b/assets/admin/components/layout/Sidebar.test.tsx index d3ffb640..4f682c35 100644 --- a/assets/admin/components/layout/Sidebar.test.tsx +++ b/assets/admin/components/layout/Sidebar.test.tsx @@ -76,3 +76,44 @@ describe("Sidebar — خدمات در منوی اصلی (نه تنظیمات)", ); }); }); + +describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () => { + const setSecretary = (resources: Record>) => + useAuthStore.setState({ + primaryRole: "secretary", + dbUuid: "c1", + userName: "منشی", + availableContexts: [], + context: { scope: "clinic", permissions: { resources } }, + } as any); + + it("منشیِ بدون مجوز، سرویس‌ها/انبار/تگ‌ها را نمی‌بیند", () => { + setSecretary({ appointments: { view: true } }); + renderWithProviders(, { route: "/admin/dashboard" }); + expect(screen.queryByText("سرویس ها")).not.toBeInTheDocument(); + expect(screen.queryByText("انبارداری")).not.toBeInTheDocument(); + expect(screen.queryByText("تگ‌ها")).not.toBeInTheDocument(); + }); + + it("با مجوز services.view، آیتم «سرویس ها» به /admin/clinic-services می‌رود", () => { + setSecretary({ services: { view: true } }); + renderWithProviders(, { route: "/admin/dashboard" }); + expect(screen.getByText("سرویس ها").closest("a")).toHaveAttribute( + "href", + "/admin/clinic-services", + ); + }); + + it("با مجوز inventory.view و tags.view، همان آیتم‌ها نمایش داده می‌شوند", () => { + setSecretary({ inventory: { view: true }, tags: { view: true } }); + renderWithProviders(, { route: "/admin/dashboard" }); + expect(screen.getByText("انبارداری").closest("a")).toHaveAttribute( + "href", + "/admin/inventory", + ); + expect(screen.getByText("تگ‌ها").closest("a")).toHaveAttribute( + "href", + "/admin/tags-settings", + ); + }); +}); diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 0c553ffa..23d03586 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -401,6 +401,27 @@ function buildSections( }, ); } + if (can("services", "view")) { + items.push({ + to: "/admin/clinic-services", + icon: WrenchScrewdriverIcon, + label: "سرویس ها", + }); + } + if (can("inventory", "view")) { + items.push({ + to: "/admin/inventory", + icon: ArchiveBoxIcon, + label: "انبارداری", + }); + } + if (can("tags", "view")) { + items.push({ + to: "/admin/tags-settings", + icon: TagIcon, + label: "تگ‌ها", + }); + } return [ { diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index 2ecc60b6..077f0713 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -75,6 +75,7 @@ const EMPTY_PERMISSIONS: SecretaryPermissions = { clinic_info: { view: false, update: false }, inventory: { view: false, create: false, update: false, delete: false }, tags: { view: false, create: false, update: false, delete: false }, + services: { view: false, create: false, update: false, delete: false }, }; type PermSection = keyof SecretaryPermissions; @@ -162,6 +163,16 @@ const PERMISSION_SECTIONS: { { key: "delete", label: "حذف تگ" }, ], }, + { + key: "services", + title: "خدمات و تعرفه‌ها", + items: [ + { key: "view", label: "مشاهده خدمات" }, + { key: "create", label: "ایجاد خدمت" }, + { key: "update", label: "ویرایش خدمت" }, + { key: "delete", label: "حذف خدمت" }, + ], + }, ]; function PermissionAccordions({ diff --git a/assets/admin/pages/SecretariesPage.tsx b/assets/admin/pages/SecretariesPage.tsx index bc18a282..739e2322 100644 --- a/assets/admin/pages/SecretariesPage.tsx +++ b/assets/admin/pages/SecretariesPage.tsx @@ -21,6 +21,7 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = { clinic_info: { view: true, update: false }, inventory: { view: false, create: false, update: false, delete: false }, tags: { view: false, create: false, update: false, delete: false }, + services: { view: false, create: false, update: false, delete: false }, }; type PermSection = keyof SecretaryPermissions; @@ -96,6 +97,15 @@ const PERMISSION_LABELS: RecordsecretaryAccess->denyUnlessGranted($user, 'services', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertServicesGate($entityType, $entityId); @@ -155,6 +158,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-section', methods: ['POST'])] public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertServicesGate($entityType, $entityId); @@ -174,6 +178,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])] public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertServicesGate($entityType, $entityId); @@ -198,6 +203,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])] public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertServicesGate($entityType, $entityId); @@ -217,6 +223,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-items', methods: ['GET'])] public function listAllItems(#[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); // A user with neither a doctor profile nor a clinic (admin, secretary, @@ -235,6 +242,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])] public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); $section = $this->sectionRepo->findByUuid($sectionUuid); @@ -248,6 +256,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-item/{uuid}', methods: ['GET'])] public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); $item = $this->itemRepo->findByUuid($uuid); @@ -262,6 +271,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])] public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); $item = $this->itemRepo->findByUuid($uuid); @@ -278,6 +288,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-item', methods: ['POST'])] public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); $this->assertServicesGate($entityType, $entityId); @@ -332,6 +343,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])] public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $item = $this->itemRepo->findByUuid($uuid); @@ -386,6 +398,7 @@ class ClinicServiceController extends BaseController #[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])] public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $item = $this->itemRepo->findByUuid($uuid); @@ -416,6 +429,7 @@ class ClinicServiceController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); $item = $this->itemRepo->findByUuid($uuid); @@ -436,6 +450,7 @@ class ClinicServiceController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); $item = $this->itemRepo->findByUuid($uuid); @@ -499,6 +514,12 @@ class ClinicServiceController extends BaseController */ private function resolveEntity(User $user): array { + // EntityContextResolver منشی را مالکِ محیط نمی‌شناسد؛ محیطِ فعالِ او را + // جداگانه به owner (کلینیک/پزشک) حل می‌کنیم. مجوز با denyUnlessGranted جداست. + if ($user->hasRole('ROLE_SECRETARY')) { + return $this->secretaryAccess->resolveOwnerEntity($user); + } + return $this->contextResolver->resolve($user, $this->requestedClinicUuid())->toEntityPair(); } diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index 6ab87b7f..e2e71305 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -28,6 +28,7 @@ class DoctorSecretary 'clinic_info' => ['view' => true, 'update' => false], 'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], + 'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], ], ]; diff --git a/src/Secretary/Security/SecretaryAccessChecker.php b/src/Secretary/Security/SecretaryAccessChecker.php index 68bf28d8..9ce8e338 100644 --- a/src/Secretary/Security/SecretaryAccessChecker.php +++ b/src/Secretary/Security/SecretaryAccessChecker.php @@ -57,6 +57,34 @@ class SecretaryAccessChecker return $relation !== null && $this->permissions->can($relation, $resource, $action); } + /** + * جفتِ [entityType, entityId] مالکِ محیطِ فعالِ منشی — کلینیک یا پزشک. + * برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی می‌کنند و resolverِ + * عمومی (EntityContextResolver) منشی را نمی‌شناسد. مجوز جدا با + * denyUnlessGranted بررسی می‌شود؛ این متد فقط owner را حل می‌کند. + * + * @return array{0: string, 1: int|null} ['clinic'|'doctor'|'unknown', id|null] + */ + public function resolveOwnerEntity(User $user): array + { + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid === null) { + return ['unknown', null]; + } + + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + return ['clinic', $clinic->getId()]; + } + + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + return ['doctor', $doctor->getId()]; + } + + return ['unknown', null]; + } + /** * برای مسیرهایی که چند نقش دارند: فقط منشی را محدود کن. سایر نقش‌ها true. */ diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index b3ac2af4..ab2ba2ff 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -77,4 +77,41 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $this->authJson('POST', '/api/v1/inventory-item', $secretary, ['name' => 'گاز استریل']); $this->assertSame(403, $this->responseCode()); } + + // service-items (listAllItems) فقط توگلِ permission را می‌سنجد — برخلاف + // service-sections که پیش از آن، گیتِ اشتراک (assertServicesGate) هم دارد. + public function testServicesDeniedByDefault(): void + { + // DEFAULT_PERMISSIONS: services.* = false + [$secretary] = $this->makeClinicSecretary(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/service-items', $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testServicesAllowedWhenGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['services' => ['view' => true]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/service-items', $secretary); + $this->assertSame(200, $this->responseCode()); + } + + public function testServicesCreateDeniedButViewGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['services' => ['view' => true, 'create' => false]]]); + $this->em->flush(); + + // مشاهده مجاز + $this->authJson('GET', '/api/v1/service-items', $secretary); + $this->assertSame(200, $this->responseCode()); + + // ایجاد ممنوع — گیتِ permission پیش از گیتِ اشتراک اجرا می‌شود. + $this->authJson('POST', '/api/v1/service-section', $secretary, ['name' => 'بخش تست']); + $this->assertSame(403, $this->responseCode()); + } }