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
+17 -5
View File
@@ -12,6 +12,7 @@ import SearchableSelect from './ui/SearchableSelect';
import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
const TYPE_LABELS: Record<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار',
@@ -72,6 +73,11 @@ function labelStyle(): React.CSSProperties { return { fontSize: 12.5, color: 'va
export default function DiscountTab() {
const qc = useQueryClient();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('discounts', 'create');
const canUpdate = can('discounts', 'update');
const canDelete = can('discounts', 'delete');
const [modal, setModal] = useState<'create' | DiscountRule | null>(null);
const [toDelete, setToDelete] = useState<DiscountRule | null>(null);
@@ -94,9 +100,11 @@ export default function DiscountTab() {
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>قوانین تخفیف عمومی بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه</span>
<button className="btn primary sm" onClick={() => setModal('create')}>
<PlusIcon style={{ width: 15 }} /> قانون جدید
</button>
{canCreate && (
<button className="btn primary sm" onClick={() => setModal('create')}>
<PlusIcon style={{ width: 15 }} /> قانون جدید
</button>
)}
</div>
{isLoading ? (
@@ -129,8 +137,12 @@ export default function DiscountTab() {
<span className={`badge ${r.active ? 'green' : ''}`}>{r.active ? 'فعال' : 'غیرفعال'}</span>
</td>
<td style={{ padding: 10, textAlign: 'left', whiteSpace: 'nowrap' }}>
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button>
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button>
{canUpdate && (
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button>
)}
{canDelete && (
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button>
)}
</td>
</tr>
))}