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
+23 -12
View File
@@ -12,6 +12,7 @@ import { formatNumber, toDate } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import PatientTagsCell from '../components/PatientTagsCell';
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
import { usePermissions } from '../hooks/usePermissions';
import {
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
} from '../components/icons/FilesToolbarIcons';
@@ -40,7 +41,7 @@ function countFilters(f: PatientFilters): number {
* A single patient card — mirrors tauri `files/list/CardView` pixel-for-pixel
* (avatar + name + ⋮ menu header, file-number/mobile rows, tags footer).
*/
function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => void; onEdit: () => void }) {
function PatientCard({ r, onView, onEdit, canUpdate }: { r: PatientRecord; onView: () => void; onEdit: () => void; canUpdate: boolean }) {
const [menu, setMenu] = useState(false);
return (
<div
@@ -66,7 +67,9 @@ function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => vo
<div style={{ position: 'fixed', inset: 0, zIndex: 60 }} onClick={(e) => { e.stopPropagation(); setMenu(false); }} />
<div style={{ position: 'absolute', top: 28, insetInlineStart: 0, zIndex: 61, minWidth: 130, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
<button type="button" className="btn sm ghost" style={{ justifyContent: 'flex-start', color: 'var(--primary)' }} onClick={(e) => { e.stopPropagation(); setMenu(false); onView(); }}><EyeIcon style={{ width: 15 }} /> مشاهده</button>
<button type="button" className="btn sm ghost" style={{ justifyContent: 'flex-start', color: 'var(--accent)' }} onClick={(e) => { e.stopPropagation(); setMenu(false); onEdit(); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
{canUpdate && (
<button type="button" className="btn sm ghost" style={{ justifyContent: 'flex-start', color: 'var(--accent)' }} onClick={(e) => { e.stopPropagation(); setMenu(false); onEdit(); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
)}
</div>
</>
)}
@@ -98,6 +101,10 @@ function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => vo
/** پرونده‌ها — patient records list. Ported from tauri /files (default card view). */
export default function PatientsListPage() {
const navigate = useNavigate();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('patients', 'create');
const canUpdate = can('patients', 'update');
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [view, setView] = useState<'table' | 'card'>('card');
@@ -183,14 +190,16 @@ export default function PatientsListPage() {
<span style={{ position: 'absolute', top: -6, insetInlineEnd: -6, minWidth: 16, height: 16, padding: '0 4px', borderRadius: 999, background: '#5559ce', color: '#fff', fontSize: 10, display: 'grid', placeItems: 'center' }}>{formatNumber(activeFilters)}</span>
)}
</button>
<button
type="button" onClick={() => navigate('/admin/patients/new')}
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
style={{ height: 48, minWidth: 137, background: '#5559ce', border: 'none', padding: '0 16px' }}
>
<AddTurn color="#fff" />
<span style={{ color: '#fff', fontSize: 14 }}>تشکیل پرونده</span>
</button>
{canCreate && (
<button
type="button" onClick={() => navigate('/admin/patients/new')}
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
style={{ height: 48, minWidth: 137, background: '#5559ce', border: 'none', padding: '0 16px' }}
>
<AddTurn color="#fff" />
<span style={{ color: '#fff', fontSize: 14 }}>تشکیل پرونده</span>
</button>
)}
</div>
</div>
</div>
@@ -242,7 +251,9 @@ export default function PatientsListPage() {
<td style={{ padding: '12px 14px' }}>
<span style={{ display: 'inline-flex', gap: 8, justifyContent: 'center' }}>
<Link to={viewHref(r)} aria-label="مشاهده" style={{ color: 'var(--primary)' }}><EyeIcon style={{ width: 18 }} /></Link>
<Link to={editHref(r)} aria-label="ویرایش" style={{ color: 'var(--accent)' }}><PencilIcon style={{ width: 18 }} /></Link>
{canUpdate && (
<Link to={editHref(r)} aria-label="ویرایش" style={{ color: 'var(--accent)' }}><PencilIcon style={{ width: 18 }} /></Link>
)}
</span>
</td>
</tr>
@@ -253,7 +264,7 @@ export default function PatientsListPage() {
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[12px] mt-[16px]">
{records.map((r) => (
<PatientCard key={r.uuid} r={r} onView={() => navigate(viewHref(r))} onEdit={() => navigate(editHref(r))} />
<PatientCard key={r.uuid} r={r} onView={() => navigate(viewHref(r))} onEdit={() => navigate(editHref(r))} canUpdate={canUpdate} />
))}
</div>
)}