feat(permissions): render both permission forms from the catalog, fix borrowed gates

The three hardcoded resource lists in the admin panel are gone. MySecretariesPage,
SecretariesPage and DoctorPermissionsModal now render from
GET /api/v1/permission-catalog, so a resource added to the backend registry shows
up in all of them with no frontend change. Each has a test that proves exactly
that by adding a resource to the mock and asserting it renders.

SecretaryPermissions was an interface with a field per resource, which made
"dynamic" impossible in TypeScript — every new resource would have been a compile
error. It is now an open map. Only two files consumed it.

The borrowed gates are corrected:
- five resource pages moved off appointment_settings onto their own 'resources'
- treatment-cases moved off appointments onto 'treatment'
- service-categories moved onto 'services', which is what ServiceCatalogController
  actually manages (categories, item groups, service relations) — not resources

TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY,
so any secretary could read and edit treatment cases. All seven of its actions are
now gated on treatment view/update.

ResourcePermissionTrait takes the resource from an overridable method instead of
hardcoding appointment_settings. HolidayController overrides it back, since the
holidays page really is appointment settings. The booking gate keeps its
appointments.view fallback so a secretary who may book is not blocked by a
resource-config permission.

Defaults were picked to preserve today's effective access, so no role gains or
loses a page from this move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 18:11:05 +03:30
co-authored by Claude Opus 5
parent dc40651308
commit ddd5f8f75a
17 changed files with 389 additions and 536 deletions
@@ -25,6 +25,12 @@ class HolidayController extends BaseController
{
use ResourcePermissionTrait;
/** تعطیلات زیرمجموعهٔ تنظیمات نوبت‌دهی است، نه فهرست منابع. */
private function permissionResource(): string
{
return 'appointment_settings';
}
public function __construct(
private readonly HolidayService $holidays,
private readonly ResourceContext $context,
@@ -10,11 +10,14 @@ use App\Shared\Exception\AppException;
use Symfony\Contracts\Service\Attribute\Required;
/**
* گِیتِ مشترک چهار کنترلر این دامنه.
* گِیتِ مشترک کنترلرهای این دامنه.
*
* مجوز `appointment_settings` بازاستفاده می‌شود و مجوز تازه‌ای ساخته نمی‌شود: منابع
* بخشی از پیکربندی نوبت‌دهی‌اند و افزودن یک کلید تازه یعنی یک ستون تازه در جدول
* مجوزهای هر منشی و هر پزشکِ عضو، بدون اینکه کسی خواسته باشد آن‌ها را جدا کند.
* منابع مجوزِ خودشان را دارند (`resources`). پیش از رجیستریِ واحد، این کنترلرها
* `appointment_settings` را قرض می‌گرفتند چون افزودن یک کلید تازه یعنی ویرایش
* دستیِ شش فهرست؛ حالا یک ردیف در `PermissionCatalog` کافی است.
*
* تعطیلات از این قاعده مستثناست و `appointment_settings` می‌ماند — صفحه‌اش
* زیرمجموعهٔ تنظیمات نوبت‌دهی است، نه فهرست دستگاه‌ها.
*/
trait ResourcePermissionTrait
{
@@ -30,11 +33,19 @@ trait ResourcePermissionTrait
$this->clinicDoctorAccess = $clinicDoctorAccess;
}
/** @param 'view'|'update' $action */
/** کنترلری که منبعِ دیگری را گِیت می‌کند این را بازنویسی می‌کند. */
private function permissionResource(): string
{
return 'resources';
}
/** @param 'view'|'create'|'update'|'delete' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
$resource = $this->permissionResource();
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
}
/**
@@ -48,9 +59,11 @@ trait ResourcePermissionTrait
*/
private function denyUnlessGrantedForBooking(User $user): void
{
$resource = $this->permissionResource();
$allowed =
($this->secretaryAccess->canOrNonSecretary($user, 'appointment_settings', 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointment_settings', 'view'))
($this->secretaryAccess->canOrNonSecretary($user, $resource, 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, $resource, 'view'))
|| ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view'));
@@ -23,6 +23,8 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Treatment')]
@@ -40,11 +42,28 @@ class TreatmentCaseController extends BaseController
private readonly TreatmentCaseEditor $editor,
private readonly TreatmentPlanProjector $planner,
private readonly \App\UserProfile\Repository\UserProfileRepository $profiles,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
/**
* تا پیش از رجیستریِ واحد، این کنترلر هیچ گِیت مجوزی نداشت و صفحه‌اش در پنل
* روی `appointments.view` سوار بود — یعنی هر منشی‌ای که اجازهٔ دیدن نوبت داشت
* پروندهٔ درمان را هم می‌دید.
*
* @param 'view'|'update' $action
*/
private function denyUnlessGranted(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'treatment', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'treatment', $action);
}
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
public function list(#[CurrentUser] User $user, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->branches->pair($user);
$status = $request->query->get('status');
@@ -88,6 +107,8 @@ class TreatmentCaseController extends BaseController
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$case = $this->requireCase($user, $uuid);
/**
@@ -111,6 +132,8 @@ class TreatmentCaseController extends BaseController
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
@@ -131,6 +154,8 @@ class TreatmentCaseController extends BaseController
#[Route('/api/v1/treatment-sessions/unbooked', name: 'treatment_sessions_unbooked', methods: ['GET'])]
public function unbooked(#[CurrentUser] User $user, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->branches->pair($user);
$withinDays = (int) $request->query->get('within_days', 7);
@@ -157,6 +182,8 @@ class TreatmentCaseController extends BaseController
#[Route('/api/v1/treatment-case/{uuid}/plan', name: 'treatment_case_plan', methods: ['GET'])]
public function plan(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$case = $this->requireCase($user, $uuid);
/**
@@ -203,6 +230,8 @@ class TreatmentCaseController extends BaseController
#[Route('/api/v1/treatment-session/{uuid}', name: 'treatment_session_show', methods: ['GET'])]
public function showSession(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$session = $this->requireSession($user, $uuid);
$case = $session->getTreatmentCase();
@@ -225,6 +254,8 @@ class TreatmentCaseController extends BaseController
#[Route('/api/v1/treatment-session/{uuid}/slot-suggestions', name: 'treatment_session_slots', methods: ['GET'])]
public function slotSuggestions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$session = $this->requireSession($user, $uuid);
$resourceUuid = $request->query->get('resource_uuid');