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>
+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>
))}
+8 -6
View File
@@ -8,7 +8,7 @@ import { digitsOnly } from '../lib/utils';
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
/** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */
export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) {
export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { doctorUuid?: string; readOnly?: boolean }) {
const qc = useQueryClient();
const [value, setValue] = useState('');
const [required, setRequired] = useState(false);
@@ -97,11 +97,13 @@ export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string })
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
<div style={{ display: 'flex', marginTop: 16 }}>
<button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
{!readOnly && (
<div style={{ display: 'flex', marginTop: 16 }}>
<button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
)}
</div>
);
}
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
@@ -42,6 +43,10 @@ export function contractSummary(c: Contract): string {
export default function TenantInsuranceContracts() {
const qc = useQueryClient();
const { dbUuid, context, availableContexts } = useAuthStore();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('insurances', 'create');
const canUpdate = can('insurances', 'update');
const [tab, setTab] = useState<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
@@ -148,9 +153,11 @@ export default function TenantInsuranceContracts() {
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>مدیریت بیمه</h2>
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
{canCreate && (
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
)}
</div>
{showDoctorPicker && (
@@ -225,6 +232,7 @@ export default function TenantInsuranceContracts() {
onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending}
canUpdate={canUpdate}
/>
))}
</div>
@@ -251,9 +259,10 @@ interface RowProps {
onEdit: () => void;
onToggleStatus: () => void;
statusPending?: boolean;
canUpdate?: boolean;
}
function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending }: RowProps) {
function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: RowProps) {
const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); };
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}>
@@ -262,14 +271,18 @@ function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus,
<ChevronDownIcon style={{ width: 15, color: 'var(--text-3)', transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none' }} />
<div style={{ fontWeight: 700, fontSize: 14 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
</div>
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
<PencilIcon style={{ width: 15 }} />
</button>
{canUpdate && (
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
<PencilIcon style={{ width: 15 }} />
</button>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
)}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
</div>
);
@@ -1,6 +1,6 @@
import { UserCircleIcon, PhoneIcon } from '@heroicons/react/24/outline';
import type { Appointment } from '../../types';
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
import AppointmentActionsMenu from '../AppointmentActions';
/**
@@ -11,12 +11,16 @@ const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', font
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle', fontSize: 13 };
export default function TurnsTable({
items, loading, queryKey, showDoctor,
items, loading, queryKey, showDoctor, canManage = true, canCancel = true,
}: {
items: Appointment[];
loading: boolean;
queryKey: unknown[];
showDoctor: boolean;
/** مجوز تغییر وضعیت (منشی)؛ پیش‌فرض true برای owner/پزشک. */
canManage?: boolean;
/** مجوز لغو نوبت (منشی)؛ پیش‌فرض true. */
canCancel?: boolean;
}) {
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (!items.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>نوبتی برای این روز ثبت نشده است</div>;
@@ -60,10 +64,18 @@ export default function TurnsTable({
<td style={td}>{a.service_item?.name || '—'}</td>
<td style={td}>{a.staff?.full_name || '—'}</td>
<td style={td}>
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
{canManage ? (
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
) : (
<span className="badge" style={{ color: STATUS_META[a.status]?.color ?? 'var(--text-2)' }}>
{STATUS_META[a.status]?.label ?? a.status}
</span>
)}
</td>
<td style={td}>
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
{(canManage || canCancel)
? <AppointmentActionsMenu appointment={a} queryKey={queryKey} />
: <span style={{ color: 'var(--text-3)' }}></span>}
</td>
</tr>
))}
@@ -2,6 +2,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react';
import { EllipsisHorizontalCircleIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import Portal from '../ui/Portal';
import type { InventoryItem } from '../../hooks/useInventory';
import { usePermissions } from '../../hooks/usePermissions';
interface Props {
item: InventoryItem;
@@ -17,7 +18,14 @@ const MENU_W = 160;
* table container's `overflow: hidden`.
*/
export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props) {
const { can } = usePermissions();
const canUpdate = can('inventory', 'update');
const canDelete = can('inventory', 'delete');
const [open, setOpen] = useState(false);
// منشیِ بدون هیچ مجوزِ ویرایش/حذف، منوی «عملیات» را اصلاً نبیند.
if (!canUpdate && !canDelete) return null;
const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const triggerRef = useRef<HTMLButtonElement>(null);
@@ -65,32 +73,36 @@ export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props)
borderRadius: 12, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
}}
>
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onEdit(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
}}
>
<PencilSquareIcon style={{ width: 20, height: 20 }} />
ویرایش
</button>
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onDelete(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'transparent', border: 'none',
cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
}}
>
<TrashIcon style={{ width: 20, height: 20 }} />
حذف
</button>
{canUpdate && (
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onEdit(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
}}
>
<PencilSquareIcon style={{ width: 20, height: 20 }} />
ویرایش
</button>
)}
{canDelete && (
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onDelete(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'transparent', border: 'none',
cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
}}
>
<TrashIcon style={{ width: 20, height: 20 }} />
حذف
</button>
)}
</div>
</Portal>
)}
@@ -4,6 +4,7 @@ import { formatRial, formatNumber } from '../../lib/utils';
import type { InventoryItem } from '../../hooks/useInventory';
import InventoryStatusBadge from './InventoryStatusBadge';
import InventoryActionsMenu from './InventoryActionsMenu';
import { usePermissions } from '../../hooks/usePermissions';
interface Props {
items: InventoryItem[];
@@ -15,6 +16,9 @@ const HEAD = ['نام کالا', 'دسته‌بندی', 'موجودی', 'واح
/** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */
export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) {
const { can } = usePermissions();
const canUpdate = can('inventory', 'update');
const canDelete = can('inventory', 'delete');
return (
<div style={{ width: '100%' }}>
{/* Desktop table */}
@@ -75,14 +79,20 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
)}
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
<PencilSquareIcon style={{ width: 16 }} />
</button>
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
<TrashIcon style={{ width: 16 }} />
</button>
</div>
{(canUpdate || canDelete) && (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
{canUpdate && (
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
<PencilSquareIcon style={{ width: 16 }} />
</button>
)}
{canDelete && (
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
<TrashIcon style={{ width: 16 }} />
</button>
)}
</div>
)}
</li>
))}
</ul>
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ChevronDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import { formatRial } from '../../lib/utils';
import type { InventoryPackage } from '../../hooks/useInventory';
import { usePermissions } from '../../hooks/usePermissions';
interface Props {
packages: InventoryPackage[];
@@ -21,6 +22,9 @@ export default function PackagesView({ packages, onEdit, onDelete }: Props) {
}
function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick<Props, 'onEdit' | 'onDelete'>) {
const { can } = usePermissions();
const canUpdate = can('inventory', 'update');
const canDelete = can('inventory', 'delete');
const [expanded, setExpanded] = useState(false);
const divider = <div style={{ height: 1, background: '#d7d7d7', margin: '8px 0' }} className="inv-divider" />;
@@ -91,20 +95,24 @@ function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick
قیمت پکیج: {formatRial(pkg.total)}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn sm ghost"
onClick={() => onDelete(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
>
<TrashIcon style={{ width: 16 }} /> حذف
</button>
<button
className="btn sm ghost"
onClick={() => onEdit(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
>
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
</button>
{canDelete && (
<button
className="btn sm ghost"
onClick={() => onDelete(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
>
<TrashIcon style={{ width: 16 }} /> حذف
</button>
)}
{canUpdate && (
<button
className="btn sm ghost"
onClick={() => onEdit(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
>
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
</button>
)}
</div>
</div>
</div>