From 5fb1658880ad62cc641a939ae9344ec93ca594ad Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 13 May 2026 13:52:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20role-based=20visibility=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20/reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1d enhancement: 'Согласования' пункт сайдбара скрыт если у user'а нет approver/reviewer ролей. Раньше показывался любому authenticated — оператор без reviewer прав видел queue, кликал и получал пустую страницу (или 403 если бы пробовал approve). Visibility rules: - 'Мои черновики' — auth (maker может быть любой) - 'Согласования' — auth + (ordinis:schema:reviewer OR ordinis:record:reviewer OR ordinis:admin super-role) — иначе hidden - 'Outbox' / 'Webhooks' / 'Аудит' — auth (admin-y но backend не gated отдельной ролью) Новый NavItem.reviewerOnly flag — фильтр в visibleSections учитывает оба flag'а (authRequired + reviewerOnly). Применено и к desktop Sidebar и к MobileSidebar (DRY refactor — отдельный task). --- .../src/components/layout/Sidebar.tsx | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/ordinis-admin-ui/src/components/layout/Sidebar.tsx b/ordinis-admin-ui/src/components/layout/Sidebar.tsx index 0d5f690..5664f80 100644 --- a/ordinis-admin-ui/src/components/layout/Sidebar.tsx +++ b/ordinis-admin-ui/src/components/layout/Sidebar.tsx @@ -14,6 +14,10 @@ import { } from 'lucide-react' import { useAuth } from 'react-oidc-context' import { useCanMutate } from '@/auth/useCanMutate' +import { + useCanReviewRecord, + useCanReviewSchema, +} from '@/auth/usePermissions' import { useDictionaries, useMyDrafts, @@ -52,12 +56,24 @@ type NavItem = { badge?: React.ReactNode /** Только аутентифицированным пользователям. */ authRequired?: boolean + /** + * Visible только если user имеет хотя бы одну из workflow ролей + * (или {@code ordinis:admin} super-role). Hide если нет. + */ + reviewerOnly?: boolean } export function Sidebar() { const { t } = useTranslation() const auth = useAuth() const canMutate = useCanMutate() + // Phase 1d role-based sidebar visibility. /reviews пункт показываем + // только тем у кого есть schema:reviewer OR record:reviewer OR + // ordinis:admin super-role. Иначе пункт скрыт — у user'а нет смысла + // его видеть (clicking всё равно даст empty queue / 403). + const canReviewSchema = useCanReviewSchema() + const canReviewRecord = useCanReviewRecord() + const isReviewer = canReviewSchema || canReviewRecord const dictsQuery = useDictionaries() // Loaded just for сидбар counter — light request, кэшируется. const myDrafts = useMyDrafts(0, 1) @@ -105,6 +121,7 @@ export function Sidebar() { icon: ClipboardList, badge: reviewsCount > 0 ? reviewsCount : undefined, authRequired: true, + reviewerOnly: true, }, { to: '/outbox', @@ -129,7 +146,11 @@ export function Sidebar() { const visibleSections = sections .map((s) => ({ ...s, - items: s.items.filter((i) => !i.authRequired || canMutate), + items: s.items.filter((i) => { + if (i.authRequired && !canMutate) return false + if (i.reviewerOnly && !isReviewer) return false + return true + }), })) .filter((s) => s.items.length > 0) @@ -418,6 +439,9 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => const { t } = useTranslation() const auth = useAuth() const canMutate = useCanMutate() + const canReviewSchema = useCanReviewSchema() + const canReviewRecord = useCanReviewRecord() + const isReviewer = canReviewSchema || canReviewRecord const dictsQuery = useDictionaries() const myDrafts = useMyDrafts(0, 1) const reviews = useReviewsBadgeCount(auth.isAuthenticated) @@ -455,6 +479,7 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => icon: ClipboardList, badge: reviewsCount > 0 ? reviewsCount : undefined, authRequired: true, + reviewerOnly: true, }, { to: '/outbox', @@ -476,7 +501,14 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => }, ] const visibleSections = sections - .map((s) => ({ ...s, items: s.items.filter((i) => !i.authRequired || canMutate) })) + .map((s) => ({ + ...s, + items: s.items.filter((i) => { + if (i.authRequired && !canMutate) return false + if (i.reviewerOnly && !isReviewer) return false + return true + }), + })) .filter((s) => s.items.length > 0) return (