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