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:
@@ -20,6 +20,7 @@ import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import FeatureGate from '../components/ui/FeatureGate';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { numericField } from '../lib/forms';
|
||||
|
||||
const chargeSchema = z.object({
|
||||
@@ -40,6 +41,10 @@ const POST_VISIT_VARS: { key: string; label: string }[] = [
|
||||
|
||||
function SmsWalletPageInner() {
|
||||
const qc = useQueryClient();
|
||||
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
|
||||
const { can } = usePermissions();
|
||||
const canCreate = can('sms', 'create'); // شارژ کیف پول
|
||||
const canUpdate = can('sms', 'update'); // ذخیره تنظیمات
|
||||
const [chargeOpen, setChargeOpen] = useState(false);
|
||||
const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat');
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
@@ -146,17 +151,19 @@ function SmsWalletPageInner() {
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
</div>
|
||||
<button
|
||||
style={{
|
||||
background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
|
||||
padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onClick={() => setChargeOpen(true)}
|
||||
>
|
||||
<DevicePhoneMobileIcon style={{ width: 16 }} />
|
||||
شارژ کیف پول
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button
|
||||
style={{
|
||||
background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
|
||||
padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onClick={() => setChargeOpen(true)}
|
||||
>
|
||||
<DevicePhoneMobileIcon style={{ width: 16 }} />
|
||||
شارژ کیف پول
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -393,15 +400,17 @@ function SmsWalletPageInner() {
|
||||
</div>
|
||||
|
||||
{/* footer ذخیره */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
|
||||
>
|
||||
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
|
||||
>
|
||||
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
|
||||
|
||||
Reference in New Issue
Block a user