feat(clinic-doctor): full permission coverage + enforcement, parity with secretary

The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the
secretary system: only 6 resources, enforced in ~6 places, dead toggles
(services.update never checked), and a sidebar showing just appointments+patients.
Bring it to parity so a clinic owner can control exactly what each member doctor
does — while an independent doctor stays completely unrestricted.

Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to
ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal
(subscription/clinic_doctors stay owner-only by design).

New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to
SecretaryAccessChecker):
- denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor
  in the clinic context; owner/admin/secretary/independent-doctor pass through.
- memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the
  role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them
  their personal tenant in clinic context.

Enforcement wired into 10 controllers alongside the existing secretary gates:
ClinicService (services), Insurance (insurances), Patient (patients+payments),
Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod.

Frontend: the guest-doctor sidebar branch now exposes every permitted resource
(gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription
Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor,
not just secretaries; my-payments route gets the missing payments permission.
CRUD-button gating already applies (usePermissions is role-agnostic).

Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed +
independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass,
frontend 503 pass. docs/api/clinic.md updated with the full resource set +
enforcement notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-23 19:09:16 +03:30
co-authored by Claude Opus 4.8
parent 1116ac14cb
commit f33c7a3eab
21 changed files with 617 additions and 31 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ export default function App() {
{/* دکتر / منشی / کلینیک */}
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPatientsPage /></RoleRoute>} />
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentsPage /></RoleRoute>} />
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['payments', 'view']}><MyPaymentsPage /></RoleRoute>} />
<Route path="my-payments/:patientUuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentDetailPage /></RoleRoute>} />
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientsListPage /></RoleRoute>} />
@@ -51,13 +51,15 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string
const primaryRole = useAuthStore((s) => s.primaryRole);
const scope = useAuthStore((s) => s.context?.scope);
const { can } = usePermissions();
// منشی، و پزشکِ عضوِ کلینیک (scope=clinic) هر دو محدود-به-مجوزند؛ پزشکِ مستقل و
// مالک آزادند و با فیلترِ نقشیِ معمول کار می‌کنند.
const permissionRestricted = primaryRole === 'secretary' || (primaryRole === 'doctor' && scope === 'clinic');
const items = useMemo(
() => NAV_ITEMS
// منشی: بر اساس مجوز، نه نقش. آیتمِ بدونِ perm/alwaysOpen پنهان است. برای
// آیتم‌هایی که واریانتِ نقشی دارند (مثلِ «نوبت‌دهی» با doctor vs clinic)، با
// scope منشی تطبیق داده می‌شود تا واریانتِ درست انتخاب شود.
// آیتمِ بدونِ perm/alwaysOpen برای کاربرِ محدود پنهان است. برای آیتم‌هایی که
// واریانتِ نقشی دارند (مثلِ «نوبت‌دهی» با doctor vs clinic)، با scope تطبیق داده می‌شود.
.filter((i) => {
if (primaryRole !== 'secretary') {
if (!permissionRestricted) {
return !i.roles || (primaryRole != null && i.roles.includes(primaryRole));
}
if (i.alwaysOpen) return true;
@@ -48,11 +48,13 @@ export function menuForRole(
can?: (resource: string, action: string) => boolean,
scope?: string | null,
): SettingsMenuItem[] {
// منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی.
const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic');
return SETTINGS_MENU.filter((i) => {
if (role === 'secretary') {
if (permissionRestricted) {
if (i.alwaysOpen) return true;
if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false;
// واریانتِ نقشی (نوبت‌دهی/پزشکان کلینیک) را با scope منشی تطبیق بده.
// واریانتِ نقشی (نوبت‌دهی/پزشکان کلینیک) را با scope تطبیق بده.
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
return true;
}
@@ -142,3 +142,31 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () =>
expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument();
});
});
describe("Sidebar — گِیت منوی پزشکِ عضوِ کلینیک بر اساس مجوز", () => {
const setMemberDoctor = (resources: Record<string, Record<string, boolean>>) =>
useAuthStore.setState({
primaryRole: "doctor",
dbUuid: "c1",
userName: "دکتر عضو",
availableContexts: [],
context: { scope: "clinic", permissions: { resources } },
} as any);
it("پزشکِ عضو فقط منابعِ مجاز را در سایدبار می‌بیند + «تنظیمات» همیشه", () => {
setMemberDoctor({ appointments: { view: true }, services: { view: true } });
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
expect(screen.getByText("نوبت‌های من")).toBeInTheDocument();
expect(screen.getByText("سرویس ها").closest("a")).toHaveAttribute("href", "/admin/clinic-services");
expect(screen.getByRole("link", { name: "تنظیمات" })).toHaveAttribute("href", "/admin/account-settings");
// بدون مجوز → پنهان
expect(screen.queryByText("انبارداری")).not.toBeInTheDocument();
expect(screen.queryByText("پرداخت‌ها")).not.toBeInTheDocument();
});
it("پزشکِ عضو بدونِ مجوزِ نوبت، آیتم نوبت را نمی‌بیند", () => {
setMemberDoctor({ services: { view: true } });
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
expect(screen.queryByText("نوبت‌های من")).not.toBeInTheDocument();
});
});
+30 -4
View File
@@ -69,9 +69,7 @@ function buildSections(
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
// برایش تعیین کرده ساخته می‌شود، نه به‌صورت hardcode.
if (primaryRole === "doctor" && scope === "clinic") {
const items: SectionItem[] = [
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
];
const items: SectionItem[] = [];
if (can("appointments", "view")) {
items.push({
to: "/admin/appointments",
@@ -88,8 +86,36 @@ function buildSections(
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" },
);
}
if (can("services", "view")) {
items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" });
}
if (can("inventory", "view")) {
items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" });
}
return [{ label: "عمومی", items }];
// زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings)
// مثل پزشکِ مستقل داخل صفحهٔ تنظیمات‌اند، نه سایدبار اصلی؛ منویِ کناریِ آن
// صفحه بر اساس مجوز فیلتر می‌شود.
return [
{
label: "عمومی",
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
},
{ label: "مدیریت", items },
{
label: "تنظیمات",
items: [{ to: "/admin/account-settings", icon: Cog6ToothIcon, label: "تنظیمات" }],
},
];
}
if (primaryRole === "admin") {
@@ -45,6 +45,34 @@ const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, s
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'];