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