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:
hamed
2026-07-23 16:36:35 +03:30
parent f00ed23f00
commit 5c4976d65f
24 changed files with 790 additions and 87 deletions
+45 -41
View File
@@ -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 },
];
}
+27 -8
View File
@@ -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'}`}>
+22
View File
@@ -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({
+20
View File
@@ -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();
});
});
+12
View File
@@ -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 {