fix(secretary): gate CRUD action buttons across all panel pages by permission

Backend already returned 403 for ungranted secretary actions, but the UI still
showed the add/edit/delete buttons (e.g. clinic-services showed «بخش جدید» to a
secretary without services.create). Sweep every secretary-reachable page so each
create/edit/delete/manage control renders only when the matching
usePermissions().can(resource, action) is true. Owner/doctor/clinic are
unaffected — can() returns true when there is no permission context — so this
restricts only secretaries and mirrors the server checks.

Pages/components gated (resource):
- services: ClinicServicesPage, ServiceDetailPage (+ its tabs)
- inventory: InventoryPage, InventoryItemsTable, InventoryActionsMenu, PackagesView
- tags: TagsSettingsPage · staff: StaffPage · discounts: DiscountTab
- sms: SmsWalletPage · insurances: TenantInsuranceContracts
- clinic_doctors: ClinicDoctorsPage + ClinicDoctorsManager (props, default true)
- patients: PatientsListPage, MyPatientsPage, PatientDetailPage (records/notes/
  sessions/attachments/calls/wallet — create/update/delete split)
- appointments: AppointmentsPage (add + empty-slot booking gated by create),
  TurnsTable (status dropdown → read-only badge without update_status; actions
  menu hidden without manage/cancel)
- appointment_settings: AppointmentSettingsPage + ClinicAppointmentSettingsPage
  pass readOnly to ScheduleSection + FreeVisitPrice (new readOnly prop)

Not gated: view/read, search, filter, tabs, navigation, export, and modal
submit buttons reachable only via an already-gated trigger.

tsc clean; full frontend suite 501/501 passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-23 18:39:58 +03:30
co-authored by Claude Opus 4.8
parent 83a6dc6158
commit a3b29404f4
21 changed files with 538 additions and 282 deletions
@@ -48,10 +48,22 @@ const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
* pending invitations (list, invite, resend, suspend, delete invitation, detach
* doctor). Reused by both the admin ClinicDetailPage and the clinic-owner
* settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control.
* `canCreate`/`canUpdate`/`canDelete` allow a caller (e.g. a secretary-scoped
* page) to gate each action group; they default to `true` so unrestricted
* callers (owner/admin) are unaffected.
*/
export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
export default function ClinicDoctorsManager({
clinicUuid,
readOnly = false,
canCreate = true,
canUpdate = true,
canDelete = true,
}: {
clinicUuid: string;
readOnly?: boolean;
canCreate?: boolean;
canUpdate?: boolean;
canDelete?: boolean;
}) {
const navigate = useNavigate();
const qc = useQueryClient();
@@ -126,7 +138,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
دعوتنامهها ({formatNumber(invitationList.length)})
</button>
</div>
{!readOnly && (
{!readOnly && canCreate && (
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button>
@@ -167,23 +179,23 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<>
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</>
{!readOnly && canUpdate && (
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
)}
{!readOnly && canDelete && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
</div>
@@ -229,9 +241,9 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span>
{!readOnly && (
{!readOnly && (canUpdate || canDelete) && (
<div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && (
{canUpdate && inv.status === 'pending' && (
<button
className="mini-btn"
title="ارسال مجدد"
@@ -241,7 +253,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<ArrowPathIcon style={{ width: 13, height: 13 }} />
</button>
)}
{inv.status !== 'removed' && inv.status !== 'accepted' && (
{canUpdate && inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title={inv.status === 'suspended' ? 'فعال‌سازی و ارسال مجدد پیامک' : 'تعلیق'}
@@ -251,14 +263,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<NoSymbolIcon style={{ width: 13, height: 13 }} />
</button>
)}
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
{canDelete && (
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
)}
</div>
)}
</div>