feat(sidebar): role-based visibility для /reviews

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).
This commit is contained in:
Zimin A.N.
2026-05-13 13:52:40 +03:00
parent 4fe7f1e800
commit 5fb1658880
@@ -14,6 +14,10 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from 'react-oidc-context' import { useAuth } from 'react-oidc-context'
import { useCanMutate } from '@/auth/useCanMutate' import { useCanMutate } from '@/auth/useCanMutate'
import {
useCanReviewRecord,
useCanReviewSchema,
} from '@/auth/usePermissions'
import { import {
useDictionaries, useDictionaries,
useMyDrafts, useMyDrafts,
@@ -52,12 +56,24 @@ type NavItem = {
badge?: React.ReactNode badge?: React.ReactNode
/** Только аутентифицированным пользователям. */ /** Только аутентифицированным пользователям. */
authRequired?: boolean authRequired?: boolean
/**
* Visible только если user имеет хотя бы одну из workflow ролей
* (или {@code ordinis:admin} super-role). Hide если нет.
*/
reviewerOnly?: boolean
} }
export function Sidebar() { export function Sidebar() {
const { t } = useTranslation() const { t } = useTranslation()
const auth = useAuth() const auth = useAuth()
const canMutate = useCanMutate() 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() const dictsQuery = useDictionaries()
// Loaded just for сидбар counter — light request, кэшируется. // Loaded just for сидбар counter — light request, кэшируется.
const myDrafts = useMyDrafts(0, 1) const myDrafts = useMyDrafts(0, 1)
@@ -105,6 +121,7 @@ export function Sidebar() {
icon: ClipboardList, icon: ClipboardList,
badge: reviewsCount > 0 ? reviewsCount : undefined, badge: reviewsCount > 0 ? reviewsCount : undefined,
authRequired: true, authRequired: true,
reviewerOnly: true,
}, },
{ {
to: '/outbox', to: '/outbox',
@@ -129,7 +146,11 @@ export function Sidebar() {
const visibleSections = sections const visibleSections = sections
.map((s) => ({ .map((s) => ({
...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) .filter((s) => s.items.length > 0)
@@ -418,6 +439,9 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
const { t } = useTranslation() const { t } = useTranslation()
const auth = useAuth() const auth = useAuth()
const canMutate = useCanMutate() const canMutate = useCanMutate()
const canReviewSchema = useCanReviewSchema()
const canReviewRecord = useCanReviewRecord()
const isReviewer = canReviewSchema || canReviewRecord
const dictsQuery = useDictionaries() const dictsQuery = useDictionaries()
const myDrafts = useMyDrafts(0, 1) const myDrafts = useMyDrafts(0, 1)
const reviews = useReviewsBadgeCount(auth.isAuthenticated) const reviews = useReviewsBadgeCount(auth.isAuthenticated)
@@ -455,6 +479,7 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
icon: ClipboardList, icon: ClipboardList,
badge: reviewsCount > 0 ? reviewsCount : undefined, badge: reviewsCount > 0 ? reviewsCount : undefined,
authRequired: true, authRequired: true,
reviewerOnly: true,
}, },
{ {
to: '/outbox', to: '/outbox',
@@ -476,7 +501,14 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
}, },
] ]
const visibleSections = sections 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) .filter((s) => s.items.length > 0)
return ( return (