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:
@@ -15,6 +15,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { ServiceSection, ServiceItem } from '../types';
|
||||
import { formatRial, formatNumber } from '../lib/utils';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import ServiceTariffModal from '../components/ServiceTariffModal';
|
||||
@@ -43,6 +44,11 @@ function Avatar({ name }: { name: string }) {
|
||||
|
||||
function ClinicServicesPageInner() {
|
||||
const qc = useQueryClient();
|
||||
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
|
||||
const { can } = usePermissions();
|
||||
const canCreate = can('services', 'create');
|
||||
const canUpdate = can('services', 'update');
|
||||
const canDelete = can('services', 'delete');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null);
|
||||
@@ -138,9 +144,11 @@ function ClinicServicesPageInner() {
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<b style={{ fontSize: 16 }}>بخشها</b>
|
||||
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
|
||||
<PlusIcon style={{ width: 16 }} /> بخش جدید
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
|
||||
<PlusIcon style={{ width: 16 }} /> بخش جدید
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sectionsLoading ? (
|
||||
@@ -180,14 +188,20 @@ function ClinicServicesPageInner() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
|
||||
<PencilIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
|
||||
{canUpdate && (
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
|
||||
<PencilIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -215,9 +229,11 @@ function ClinicServicesPageInner() {
|
||||
<button className="btn sm ghost" onClick={() => setShowInactive((v) => !v)} title={showInactive ? 'پنهانکردن غیرفعالها' : 'نمایش غیرفعالها'}>
|
||||
{showInactive ? <EyeIcon style={{ width: 16 }} /> : <EyeSlashIcon style={{ width: 16 }} />}
|
||||
</button>
|
||||
<button className="cp-btn-primary" onClick={openCreateItem}>
|
||||
<PlusIcon style={{ width: 16 }} /> سرویس جدید
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button className="cp-btn-primary" onClick={openCreateItem}>
|
||||
<PlusIcon style={{ width: 16 }} /> سرویس جدید
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +245,7 @@ function ClinicServicesPageInner() {
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>
|
||||
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
|
||||
</div>
|
||||
{allItems.length === 0 && (
|
||||
{allItems.length === 0 && canCreate && (
|
||||
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={openCreateItem}>افزودن سرویس</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -262,9 +278,11 @@ function ClinicServicesPageInner() {
|
||||
>
|
||||
{/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||
<button className="btn sm ghost" style={{ padding: 4 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === item.uuid ? null : item.uuid); }} title="عملیات">
|
||||
<EllipsisHorizontalIcon style={{ width: 20 }} />
|
||||
</button>
|
||||
{canUpdate ? (
|
||||
<button className="btn sm ghost" style={{ padding: 4 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === item.uuid ? null : item.uuid); }} title="عملیات">
|
||||
<EllipsisHorizontalIcon style={{ width: 20 }} />
|
||||
</button>
|
||||
) : <span />}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{item.active ? 'فعال' : 'غیرفعال'}
|
||||
|
||||
Reference in New Issue
Block a user