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:
@@ -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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user