From 073a68bcc226cffb27acc3e6b46d3f3f62fd740e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 18:52:25 +0330 Subject: [PATCH] fix(admin): split the catalog page buttons onto per-action services permissions The backend gate is now per-action, so a single canUpdate driving add, edit and delete would show buttons the server answers 403 to. Each button now checks its own action, and the includes modal takes canCreate/canDelete so its add select and per-edge remove button follow the same split. ServiceCategoryTab is deliberately left alone: its save patches /api/v1/service-item/{uuid}, which is ClinicServiceController and already gated on services.update, so canEdit was already the right permission. Only its read of the category tree moved behind services.view, and the page it lives on already requires that. The page test now drives a configurable can(), covering view-only, create-only, update-only and delete-only. Co-Authored-By: Claude Opus 5 --- .../pages/CatalogCategoriesPage.test.tsx | 64 ++++++++++++++++++- assets/admin/pages/CatalogCategoriesPage.tsx | 61 +++++++++++------- 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/assets/admin/pages/CatalogCategoriesPage.test.tsx b/assets/admin/pages/CatalogCategoriesPage.test.tsx index 7076f7d9..ff061842 100644 --- a/assets/admin/pages/CatalogCategoriesPage.test.tsx +++ b/assets/admin/pages/CatalogCategoriesPage.test.tsx @@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../test/utils'; vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); -vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) })); +// per-action قابل تنظیم: گِیتِ ServiceCatalogController هم per-action است، پس تست باید +// بتواند create/update/delete را جدا خاموش کند. +let granted = new Set(['view', 'create', 'update', 'delete']); +vi.mock('../hooks/usePermissions', () => ({ + usePermissions: () => ({ can: (_r: string, a: string) => granted.has(a) }), +})); const create = { mutate: vi.fn(), isPending: false }; const update = { mutate: vi.fn(), isPending: false }; @@ -34,6 +39,7 @@ import CatalogCategoriesPage from './CatalogCategoriesPage'; describe('CatalogCategoriesPage', () => { beforeEach(() => { includes = []; + granted = new Set(['view', 'create', 'update', 'delete']); vi.clearAllMocks(); }); @@ -77,4 +83,60 @@ describe('CatalogCategoriesPage', () => { expect(removeEdge.mutate).toHaveBeenCalledWith({ uuid: 'c-whole', childUuid: 'c-hand' }); }); + // ── هم‌ترازی با گِیتِ per-action سمت API ───────────────────────────────── + // یک توگلِ واحد یعنی دکمه‌ای که کاربر می‌بیند و سرور ۴۰۳ می‌دهد. + + it('با فقط update، دکمهٔ افزودن نیست ولی ویرایش هست', () => { + granted = new Set(['view', 'update']); + renderWithProviders(); + + expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی/ })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'ویرایش' }).length).toBeGreaterThan(0); + }); + + it('با فقط create، دکمهٔ افزودن هست ولی ویرایش و حذف نیست', () => { + granted = new Set(['view', 'create']); + renderWithProviders(); + + expect(screen.getByRole('button', { name: /افزودن دسته‌بندی/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument(); + }); + + it('با فقط delete، دکمهٔ حذف هست ولی ویرایش نیست', () => { + granted = new Set(['view', 'delete']); + renderWithProviders(); + + expect(screen.getAllByRole('button', { name: 'حذف' }).length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument(); + }); + + it('با فقط view، هیچ دکمهٔ نوشتنی نیست', () => { + granted = new Set(['view']); + renderWithProviders(); + + expect(screen.getByText('تمام بدن')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'شامل' })).not.toBeInTheDocument(); + }); + + it('مودالِ «شامل» دکمهٔ حذف یال را فقط با delete نشان می‌دهد', async () => { + includes = [{ uuid: 'c-hand', name: 'دست' }]; + granted = new Set(['view', 'delete']); + const withDelete = renderWithProviders(); + + await userEvent.click(screen.getAllByRole('button', { name: 'شامل' })[0]); + expect(await screen.findByRole('button', { name: 'حذف دست' })).toBeInTheDocument(); + + withDelete.unmount(); + + granted = new Set(['view', 'create']); + renderWithProviders(); + + await userEvent.click(screen.getAllByRole('button', { name: 'شامل' })[0]); + expect(screen.queryByRole('button', { name: 'حذف دست' })).not.toBeInTheDocument(); + }); + }); diff --git a/assets/admin/pages/CatalogCategoriesPage.tsx b/assets/admin/pages/CatalogCategoriesPage.tsx index c5248b06..afec00e8 100644 --- a/assets/admin/pages/CatalogCategoriesPage.tsx +++ b/assets/admin/pages/CatalogCategoriesPage.tsx @@ -30,7 +30,11 @@ function flatten(nodes: CatalogCategory[], depth = 0): Row[] { export default function CatalogCategoriesPage() { const { tree, loading, create, update, remove } = useCatalogCategories(); const { can } = usePermissions(); + // per-action، هم‌تراز با گِیتِ ServiceCatalogController؛ یک توگلِ واحد یعنی دکمه‌ای + // که کاربر می‌بیند ولی سرور ۴۰۳ می‌دهد. + const canCreate = can('services', 'create'); const canUpdate = can('services', 'update'); + const canDelete = can('services', 'delete'); const [urlState, setUrlState] = useUrlState({ search: '' }); const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null }); @@ -67,7 +71,7 @@ export default function CatalogCategoriesPage() { description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند." backTo="/admin/settings-menu" action={ - canUpdate ? ( + canCreate ? ( @@ -84,18 +88,25 @@ export default function CatalogCategoriesPage() { searchPlaceholder="جستجو در دسته‌بندی‌ها..." emptyMessage="هنوز دسته‌بندی‌ای تعریف نشده است" actions={ - canUpdate + canUpdate || canCreate || canDelete ? (c) => (
- - - + {canUpdate && ( + + )} + {/* مودالِ «شامل» یال اضافه/حذف می‌کند: create یا delete. */} + {(canCreate || canDelete) && ( + + )} + {canDelete && ( + + )}
) : undefined @@ -122,6 +133,8 @@ export default function CatalogCategoriesPage() { setIncludesFor(null)} /> @@ -231,10 +244,12 @@ function CategoryFormModal({ /** «این دسته شامل کدام دسته‌هاست» — گراف، جدا از درختِ منو. */ function CategoryIncludesModal({ - category, all, onClose, + category, all, canCreate, canDelete, onClose, }: { category: CatalogCategory | null; all: Row[]; + canCreate: boolean; + canDelete: boolean; onClose: () => void; }) { const { includes, add, remove } = useCategoryIncludes(category?.uuid); @@ -257,21 +272,23 @@ function CategoryIncludesModal({ {includes.map((child) => (
{child.name} - + {canDelete && ( + + )}
))} )} - {available.length > 0 && ( + {canCreate && available.length > 0 && (