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
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../../lib/api';
import DoctorPermissionsModal from './DoctorPermissionsModal';
const get = api.get as ReturnType<typeof vi.fn>;
const resource = (key: string, label: string, actions: [string, string][]) => ({
key,
label,
clinic_only: false,
actions: actions.map(([k, l]) => ({ key: k, label: l })),
});
const BASE_CATALOG = [
resource('appointments', 'مدیریت نوبت‌ها', [['view', 'مشاهده'], ['create', 'ایجاد']]),
resource('patients', 'پرونده بیماران', [['view', 'مشاهده'], ['delete', 'حذف']]),
resource('services', 'خدمات و تعرفه‌ها', [['view', 'مشاهده']]),
];
const permissionPayload = {
uuid: 'p1',
clinic_uuid: 'c1',
doctor_uuid: 'd1',
doctor_name: 'دکتر تست',
active: true,
permissions: { version: 1, resources: { appointments: { view: true, create: false } } },
};
function mockApi(catalog: unknown[]) {
get.mockImplementation((url: string) => {
if (url.includes('permission-catalog')) {
return Promise.resolve({ success: true, data: { version: 1, resources: catalog } });
}
return Promise.resolve({ success: true, data: permissionPayload });
});
}
function render(catalog: unknown[] = BASE_CATALOG) {
mockApi(catalog);
return renderWithProviders(
<DoctorPermissionsModal clinicUuid="c1" doctorUuid="d1" doctorName="دکتر تست" onClose={() => {}} />,
);
}
describe('DoctorPermissionsModal', () => {
beforeEach(() => vi.clearAllMocks());
it('renders one row per catalog resource', async () => {
render();
expect(await screen.findByText('مدیریت نوبت‌ها')).toBeInTheDocument();
expect(screen.getByText('پرونده بیماران')).toBeInTheDocument();
expect(screen.getByText('خدمات و تعرفه‌ها')).toBeInTheDocument();
});
/**
* قلبِ «داینامیک بودن»: منبع تازه فقط به mock اضافه می‌شود و هیچ خطی از کد
* کامپوننت عوض نمی‌شود. اگر این تست بشکند یعنی فهرست دوباره هاردکد شده.
*/
it('shows a resource added to the catalog with no code change', async () => {
render([...BASE_CATALOG, resource('brand_new_page', 'صفحهٔ کاملاً تازه', [['view', 'مشاهده']])]);
expect(await screen.findByText('صفحهٔ کاملاً تازه')).toBeInTheDocument();
});
it('renders a switch only for actions the resource actually has', async () => {
render();
// patients در کاتالوگ view و delete دارد، ولی create ندارد.
expect(await screen.findByLabelText('پرونده بیماران — حذف')).toBeInTheDocument();
expect(screen.queryByLabelText('پرونده بیماران — ایجاد')).not.toBeInTheDocument();
});
it('reflects stored values and defaults missing ones to off', async () => {
render();
await waitFor(() => expect(screen.getByLabelText('مدیریت نوبت‌ها — مشاهده')).toBeChecked());
expect(screen.getByLabelText('مدیریت نوبت‌ها — ایجاد')).not.toBeChecked();
// services اصلاً در JSONِ ذخیره‌شده نیست
expect(screen.getByLabelText('خدمات و تعرفه‌ها — مشاهده')).not.toBeChecked();
});
it('shows loading rather than an empty table while the catalog is in flight', () => {
get.mockImplementation(() => new Promise(() => {}));
renderWithProviders(
<DoctorPermissionsModal clinicUuid="c1" doctorUuid="d1" doctorName="دکتر تست" onClose={() => {}} />,
);
expect(screen.getByText('در حال بارگذاری...')).toBeInTheDocument();
});
});
@@ -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>