feat(permissions): render both permission forms from the catalog, fix borrowed gates

The three hardcoded resource lists in the admin panel are gone. MySecretariesPage,
SecretariesPage and DoctorPermissionsModal now render from
GET /api/v1/permission-catalog, so a resource added to the backend registry shows
up in all of them with no frontend change. Each has a test that proves exactly
that by adding a resource to the mock and asserting it renders.

SecretaryPermissions was an interface with a field per resource, which made
"dynamic" impossible in TypeScript — every new resource would have been a compile
error. It is now an open map. Only two files consumed it.

The borrowed gates are corrected:
- five resource pages moved off appointment_settings onto their own 'resources'
- treatment-cases moved off appointments onto 'treatment'
- service-categories moved onto 'services', which is what ServiceCatalogController
  actually manages (categories, item groups, service relations) — not resources

TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY,
so any secretary could read and edit treatment cases. All seven of its actions are
now gated on treatment view/update.

ResourcePermissionTrait takes the resource from an overridable method instead of
hardcoding appointment_settings. HolidayController overrides it back, since the
holidays page really is appointment settings. The booking gate keeps its
appointments.view fallback so a secretary who may book is not blocked by a
resource-config permission.

Defaults were picked to preserve today's effective access, so no role gains or
loses a page from this move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 18:11:05 +03:30
co-authored by Claude Opus 5
parent dc40651308
commit ddd5f8f75a
17 changed files with 389 additions and 536 deletions
@@ -5,6 +5,7 @@ import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import Modal from './Modal';
import Switch from './Switch';
import { usePermissionCatalog } from '../../hooks/usePermissionCatalog';
/** envelope کامل — همان چیزی که بک‌اند برمی‌گرداند، بدون flatten. */
export interface PermissionEnvelope {
@@ -21,61 +22,7 @@ export interface ClinicDoctorPermissionPayload {
permissions: PermissionEnvelope;
}
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
appointments: {
label: 'نوبت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
},
appointment_settings: {
label: 'تنظیمات نوبت‌دهی',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
patients: {
label: 'پرونده بیماران',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
payments: {
label: 'پرداخت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
services: {
label: 'خدمات',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
clinic_info: {
label: 'اطلاعات کلینیک',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
insurances: {
label: 'بیمه‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
addresses: {
label: 'آدرس‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
inventory: {
label: 'انبارداری',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
tags: {
label: 'تگ‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
staff: {
label: 'پرسنل',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
discounts: {
label: 'تخفیف‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
sms: {
label: 'پیامک‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
};
/** ستون‌های ثابتِ جدول؛ هر منبع فقط ستون‌هایی را پر می‌کند که کاتالوگ برایش داده. */
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
@@ -86,6 +33,7 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
onClose: () => void;
}) {
const qc = useQueryClient();
const catalog = usePermissionCatalog();
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
const [active, setActive] = useState(true);
@@ -135,7 +83,7 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
<button
className="btn primary sm"
disabled={saveMut.isPending || permQ.isLoading}
disabled={saveMut.isPending || permQ.isLoading || catalog.isLoading}
onClick={() => saveMut.mutate()}
>
ذخیره
@@ -143,8 +91,10 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
</>
}
>
{permQ.isLoading ? (
{permQ.isLoading || catalog.isLoading ? (
<p className="muted">در حال بارگذاری...</p>
) : catalog.isError ? (
<p className="muted">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
) : (
<>
<div style={{ marginBottom: 14 }}>
@@ -167,13 +117,13 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
</tr>
</thead>
<tbody>
{Object.keys(RESOURCE_LABELS).map(resource => {
const config = RESOURCE_LABELS[resource];
{catalog.resources.map(resource => {
const available = new Set(resource.actions.map(a => a.key));
return (
<tr key={resource}>
<td><b>{config.label}</b></td>
<tr key={resource.key}>
<td><b>{resource.label}</b></td>
{ACTION_COLUMNS.map(action => {
if (!config.actions[action]) {
if (!available.has(action)) {
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
@@ -183,9 +133,9 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Switch
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
ariaLabel={`${config.label}${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
checked={resources[resource.key]?.[action] ?? false}
onChange={() => toggle(resource.key, action)}
ariaLabel={`${resource.label}${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
/>
</div>
</td>