feat: Implement secretary permissions enforcement across multiple resources
- Added SecretaryAccessChecker to manage resource access for secretaries. - Integrated permission checks for payments, inventory, and tags in relevant controllers. - Updated PaymentController and PaymentMethodController to enforce secretary permissions. - Enhanced TenantTagController to check permissions for tag management actions. - Introduced tests for secretary resource enforcement, ensuring proper access control. - Updated DoctorSecretary entity to include inventory and tags permissions. - Created a comprehensive audit document for secretary permissions coverage and enforcement. - Fixed potential crashes in SecretaryDashboard when rendering without doctor data.
This commit is contained in:
@@ -359,51 +359,55 @@ function buildSections(
|
||||
}
|
||||
|
||||
if (primaryRole === "secretary") {
|
||||
// منو از روی مجوزهای همان منشی ساخته میشود: آیتمی که مجوز مشاهدهاش را
|
||||
// ندارد اصلاً نمایش داده نمیشود (همراستا با enforcement سمت API).
|
||||
const items: SectionItem[] = [];
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
||||
});
|
||||
}
|
||||
if (can("patients", "view")) {
|
||||
items.push({
|
||||
to: "/admin/patients",
|
||||
icon: FolderOpenIcon,
|
||||
label: "پرونده بیماران",
|
||||
feature: "patient_records",
|
||||
});
|
||||
}
|
||||
if (can("payments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/my-payments",
|
||||
icon: CreditCardIcon,
|
||||
label: "پرداختها",
|
||||
});
|
||||
}
|
||||
if (can("insurances", "view")) {
|
||||
items.push(
|
||||
{
|
||||
to: "/admin/insurance-pricing",
|
||||
icon: ShieldCheckIcon,
|
||||
label: "قیمتگذاری بیمه",
|
||||
feature: "insurance",
|
||||
},
|
||||
{
|
||||
to: "/admin/claims",
|
||||
icon: DocumentTextIcon,
|
||||
label: "مطالبات بیمه",
|
||||
feature: "insurance",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
label: "داشبورد",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "مدیریت",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
||||
},
|
||||
{
|
||||
to: "/admin/patients",
|
||||
icon: FolderOpenIcon,
|
||||
label: "پرونده بیماران",
|
||||
feature: "patient_records",
|
||||
},
|
||||
{
|
||||
to: "/admin/my-payments",
|
||||
icon: CreditCardIcon,
|
||||
label: "پرداختها",
|
||||
},
|
||||
{
|
||||
to: "/admin/insurance-pricing",
|
||||
icon: ShieldCheckIcon,
|
||||
label: "قیمتگذاری بیمه",
|
||||
feature: "insurance",
|
||||
},
|
||||
{
|
||||
to: "/admin/claims",
|
||||
icon: DocumentTextIcon,
|
||||
label: "مطالبات بیمه",
|
||||
feature: "insurance",
|
||||
},
|
||||
],
|
||||
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
||||
},
|
||||
{ label: "مدیریت", items },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -869,7 +869,9 @@ function DoctorDashboard() {
|
||||
// ── Secretary Dashboard ───────────────────────────────────────────────────
|
||||
|
||||
interface SecretaryDashboardData {
|
||||
doctor: { uuid: string; name: string; degree: string | null };
|
||||
scope: 'doctor' | 'clinic';
|
||||
doctor?: { uuid: string; name: string; degree: string | null };
|
||||
clinic?: { uuid: string; name: string };
|
||||
permissions: Record<string, unknown>;
|
||||
stats: { today_appointments: number; tomorrow_appointments: number };
|
||||
today_appointments: ApptRow[];
|
||||
@@ -888,20 +890,37 @@ function SecretaryDashboard() {
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
if (q.isError || !d) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
|
||||
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
|
||||
<p className="muted" style={{ fontSize: 13.5 }}>در حال حاضر اطلاعات داشبورد در دسترس نیست.</p>
|
||||
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// منشیِ scope=clinic فیلد doctor ندارد و scope=doctor فیلد clinic؛ نام محیط بر اساس scope.
|
||||
const scopeName = d.scope === 'clinic' ? (d.clinic?.name ?? '') : displayDoctorName(d.doctor?.name);
|
||||
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
|
||||
|
||||
const kpiCards = [
|
||||
{ label: 'نوبتهای امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'نوبتهای فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'نوبتهای امروز', value: formatNumber(d.stats?.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'نوبتهای فردا', value: formatNumber(d.stats?.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const canViewAppts = (d?.permissions as any)?.resources?.appointments?.view ?? false;
|
||||
const canViewAppts = (d.permissions as any)?.resources?.appointments?.view ?? false;
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد منشی</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {displayDoctorName(d?.doctor.name)}</div>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {scopeLabel} {scopeName}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
@@ -910,10 +929,10 @@ function SecretaryDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
|
||||
<AvatarEl initials={(scopeName || 'D').slice(0, 1)} hue={256} size="lg" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{displayDoctorName(d?.doctor.name) || '—'}</div>
|
||||
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{scopeName || '—'}</div>
|
||||
{d.scope === 'doctor' && d.doctor?.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
|
||||
</div>
|
||||
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
|
||||
<span className={`badge ${canViewAppts ? 'green' : 'gray'}`}>
|
||||
|
||||
@@ -73,6 +73,8 @@ const EMPTY_PERMISSIONS: SecretaryPermissions = {
|
||||
insurances: { view: false, create: false, update: false, delete: false },
|
||||
addresses: { view: false, create: false, update: false, delete: false },
|
||||
clinic_info: { view: false, update: false },
|
||||
inventory: { view: false, create: false, update: false, delete: false },
|
||||
tags: { view: false, create: false, update: false, delete: false },
|
||||
};
|
||||
|
||||
type PermSection = keyof SecretaryPermissions;
|
||||
@@ -140,6 +142,26 @@ const PERMISSION_SECTIONS: {
|
||||
{ key: "update", label: "ویرایش اطلاعات" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "inventory",
|
||||
title: "انبار",
|
||||
items: [
|
||||
{ key: "view", label: "مشاهده انبار" },
|
||||
{ key: "create", label: "ایجاد کالا/بسته" },
|
||||
{ key: "update", label: "ویرایش انبار" },
|
||||
{ key: "delete", label: "حذف از انبار" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
title: "تگها",
|
||||
items: [
|
||||
{ key: "view", label: "مشاهده تگها" },
|
||||
{ key: "create", label: "ایجاد تگ" },
|
||||
{ key: "update", label: "ویرایش تگ" },
|
||||
{ key: "delete", label: "حذف تگ" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function PermissionAccordions({
|
||||
|
||||
@@ -19,6 +19,8 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
||||
insurances: { view: true, create: false, update: false, delete: false },
|
||||
addresses: { view: true, create: false, update: false, delete: false },
|
||||
clinic_info: { view: true, update: false },
|
||||
inventory: { view: false, create: false, update: false, delete: false },
|
||||
tags: { view: false, create: false, update: false, delete: false },
|
||||
};
|
||||
|
||||
type PermSection = keyof SecretaryPermissions;
|
||||
@@ -76,6 +78,24 @@ const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: st
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
inventory: {
|
||||
label: 'انبار',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
tags: {
|
||||
label: 'تگها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function PermissionsMatrix({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } 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 {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const perms = {
|
||||
version: 1,
|
||||
resources: { appointments: { view: true, create: true, cancel: false, update_status: true } },
|
||||
};
|
||||
|
||||
/** منشیِ scope=clinic: پاسخ کلید `doctor` ندارد — نباید کرش کند. */
|
||||
const clinicScope = {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'clinic',
|
||||
clinic: { uuid: 'clinic-1', name: 'کلینیک نمونه' },
|
||||
permissions: perms,
|
||||
stats: { today_appointments: 3, tomorrow_appointments: 5 },
|
||||
today_appointments: [],
|
||||
},
|
||||
};
|
||||
|
||||
const doctorScope = {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'doctor',
|
||||
doctor: { uuid: 'doc-1', name: 'دکتر رضایی', degree: 'متخصص قلب' },
|
||||
permissions: perms,
|
||||
stats: { today_appointments: 1, tomorrow_appointments: 2 },
|
||||
today_appointments: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe('SecretaryDashboard', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'secretary', dbUuid: 'clinic-1', context: null } as never);
|
||||
});
|
||||
|
||||
it('منشیِ کلینیک را بدون کرش رندر میکند (پاسخ بدون فیلد doctor)', async () => {
|
||||
get.mockImplementation((url: string) =>
|
||||
url.includes('/dashboard/secretary') ? Promise.resolve(clinicScope) : Promise.resolve({ success: true, data: [] }),
|
||||
);
|
||||
|
||||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
|
||||
expect(await screen.findByText('داشبورد منشی')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/کلینیک نمونه/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('نوبتهای امروز').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('منشیِ مطب پزشک را با نام پزشک رندر میکند', async () => {
|
||||
get.mockImplementation((url: string) =>
|
||||
url.includes('/dashboard/secretary') ? Promise.resolve(doctorScope) : Promise.resolve({ success: true, data: [] }),
|
||||
);
|
||||
|
||||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
|
||||
expect(await screen.findByText('داشبورد منشی')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/دکتر رضایی/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('پاسخ خالی/خطا را بدون کرش با پیام مناسب نشان میدهد', async () => {
|
||||
get.mockImplementation((url: string) =>
|
||||
url.includes('/dashboard/secretary')
|
||||
? Promise.resolve({ success: false, data: null, errors: [{ code: 'X', message: 'x' }] })
|
||||
: Promise.resolve({ success: true, data: [] }),
|
||||
);
|
||||
|
||||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
|
||||
expect(await screen.findByText('در حال حاضر اطلاعات داشبورد در دسترس نیست.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -490,6 +490,18 @@ export interface SecretaryPermissions {
|
||||
view: boolean;
|
||||
update: boolean;
|
||||
};
|
||||
inventory: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
tags: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Specialty {
|
||||
|
||||
Reference in New Issue
Block a user