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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 18:52:25 +03:30
co-authored by Claude Opus 5
parent 52c45443c5
commit 073a68bcc2
2 changed files with 102 additions and 23 deletions
@@ -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<string>(['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(<CatalogCategoriesPage />);
expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی/ })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'ویرایش' }).length).toBeGreaterThan(0);
});
it('با فقط create، دکمهٔ افزودن هست ولی ویرایش و حذف نیست', () => {
granted = new Set(['view', 'create']);
renderWithProviders(<CatalogCategoriesPage />);
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(<CatalogCategoriesPage />);
expect(screen.getAllByRole('button', { name: 'حذف' }).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'ویرایش' })).not.toBeInTheDocument();
});
it('با فقط view، هیچ دکمهٔ نوشتنی نیست', () => {
granted = new Set(['view']);
renderWithProviders(<CatalogCategoriesPage />);
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(<CatalogCategoriesPage />);
await userEvent.click(screen.getAllByRole('button', { name: 'شامل' })[0]);
expect(await screen.findByRole('button', { name: 'حذف دست' })).toBeInTheDocument();
withDelete.unmount();
granted = new Set(['view', 'create']);
renderWithProviders(<CatalogCategoriesPage />);
await userEvent.click(screen.getAllByRole('button', { name: 'شامل' })[0]);
expect(screen.queryByRole('button', { name: 'حذف دست' })).not.toBeInTheDocument();
});
});
+39 -22
View File
@@ -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 ? (
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
</button>
@@ -84,18 +88,25 @@ export default function CatalogCategoriesPage() {
searchPlaceholder="جستجو در دسته‌بندی‌ها..."
emptyMessage="هنوز دسته‌بندی‌ای تعریف نشده است"
actions={
canUpdate
canUpdate || canCreate || canDelete
? (c) => (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, category: c })}>
ویرایش
</button>
<button type="button" className="btn secondary sm" onClick={() => setIncludesFor(c)}>
شامل
</button>
<button type="button" className="btn secondary sm" onClick={() => setToDelete(c)}>
حذف
</button>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, category: c })}>
ویرایش
</button>
)}
{/* مودالِ «شامل» یال اضافه/حذف می‌کند: create یا delete. */}
{(canCreate || canDelete) && (
<button type="button" className="btn secondary sm" onClick={() => setIncludesFor(c)}>
شامل
</button>
)}
{canDelete && (
<button type="button" className="btn secondary sm" onClick={() => setToDelete(c)}>
حذف
</button>
)}
</div>
)
: undefined
@@ -122,6 +133,8 @@ export default function CatalogCategoriesPage() {
<CategoryIncludesModal
category={includesFor}
all={all}
canCreate={canCreate}
canDelete={canDelete}
onClose={() => 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) => (
<div key={child.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{child.name}</span>
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => category && remove.mutate({ uuid: category.uuid, childUuid: child.uuid })}
aria-label={`حذف ${child.name}`}
>
</button>
{canDelete && (
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => category && remove.mutate({ uuid: category.uuid, childUuid: child.uuid })}
aria-label={`حذف ${child.name}`}
>
</button>
)}
</div>
))}
</div>
)}
{available.length > 0 && (
{canCreate && available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن زیرمجموعه</label>
<SearchableSelect