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
+64 -40
View File
@@ -41,6 +41,7 @@ import PageHeader from "../components/ui/PageHeader";
import Pagination from "../components/ui/Pagination";
import SearchableSelect from "../components/ui/SearchableSelect";
import PatientRecordInfoForm from "../components/PatientRecordInfoForm";
import { usePermissions } from "../hooks/usePermissions";
import {
GENDER_OPTS,
MARITAL_OPTS,
@@ -193,6 +194,10 @@ function calcFinalPrice(
function MyPatientsPageInner() {
const qc = useQueryClient();
const navigate = useNavigate();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can("patients", "create");
const canUpdate = can("patients", "update");
const [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(
null,
);
@@ -586,13 +591,15 @@ function MyPatientsPageInner() {
title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما"
action={
<button
className="btn primary sm"
onClick={() => setCreateRecordOpen(true)}
>
<UserPlusIcon style={{ width: 16 }} />
پرونده جدید
</button>
canCreate ? (
<button
className="btn primary sm"
onClick={() => setCreateRecordOpen(true)}
>
<UserPlusIcon style={{ width: 16 }} />
پرونده جدید
</button>
) : undefined
}
/>
@@ -1010,12 +1017,14 @@ function MyPatientsPageInner() {
>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<button
className="btn primary sm"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
{canUpdate && (
<button
className="btn primary sm"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
)}
</div>
}
/>
@@ -1087,17 +1096,19 @@ function MyPatientsPageInner() {
</span>
</span>
</div>
<button
onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")}
style={{
background: "#F17732", color: "#fff", border: "none", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
}}
>
یادداشت
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
</button>
{canUpdate && (
<button
onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")}
style={{
background: "#F17732", color: "#fff", border: "none", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
}}
>
یادداشت
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
</button>
)}
</div>
</div>
@@ -1137,9 +1148,11 @@ function MyPatientsPageInner() {
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>اطلاعات بیمار</div>
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
{canUpdate && (
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
{([
@@ -1204,12 +1217,14 @@ function MyPatientsPageInner() {
) : (
<>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button
className="cp-btn-primary"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button>
{canUpdate && (
<button
className="cp-btn-primary"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button>
)}
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
<FunnelIcon style={{ width: 18 }} />
</button>
@@ -1285,9 +1300,11 @@ function MyPatientsPageInner() {
</button>
)
) : (
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
تکمیل پرداخت
</button>
canUpdate && (
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
تکمیل پرداخت
</button>
)
)}
</td>
</tr>
@@ -1355,6 +1372,7 @@ function MyPatientsPageInner() {
onViewInvoice={viewInvoice}
issuing={issueInvoiceMut.isPending}
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
canUpdate={canUpdate}
/>
<Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}>
@@ -1884,6 +1902,7 @@ function VisitDetailModal({
onSettle,
onViewInvoice,
onEdit,
canUpdate,
}: {
session: PatientSession | null;
onClose: () => void;
@@ -1893,6 +1912,7 @@ function VisitDetailModal({
/** کل session پاس می‌شود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */
onViewInvoice: (session: PatientSession) => void;
onEdit: (s: PatientSession) => void;
canUpdate: boolean;
}) {
if (!session) return null;
const services = session.services ?? [];
@@ -1917,15 +1937,19 @@ function VisitDetailModal({
size="md"
footer={
<>
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
{canUpdate && (
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
)}
{paid ? (
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button>
) : (
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
canUpdate && (
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
)
)}
</>
}