feat(roles): фильтр sidebar и route-guard по ролям пользователя

Раньше Sidebar строился только по флагу isSuperAdmin, и Кассир/
Кладовщик видели весь меню (включая «Сотрудники», «Контрагенты»,
«Настройки»), хотя серверные [Authorize(Roles = "Admin")] возвращали
бы 403 на каждом запросе.

Теперь:
- AppLayout.buildNav берёт roles[] из /api/me и собирает меню per-role:
  · Каталог: Кассиру/Кладовщику только Товары; Admin — всё.
  · Контрагенты: только Admin.
  · Остатки: видят все три tenant-роли. Движения — Admin/Storekeeper.
  · Закупки: Admin/Storekeeper.
  · Продажи: Admin/Cashier.
  · Импорт МойСклад, Настройки организации, Сотрудники, Роли,
    Склады, Кассы — только Admin.
  · Системная консоль — только SuperAdmin.

- Новый компонент RoleGuard (web/components/RoleGuard.tsx). Показывает
  «Нет доступа» вместо страницы если у юзера нет нужной роли. Применён
  в App.tsx для всех admin-only роутов: /settings/*, /catalog/{stores,
  retail-points,counterparties}, /admin/import/moysklad. Защищает на
  случай прямого ввода URL — sidebar их уже не показывает, но без
  guard юзер увидел бы крутящееся колесо и 403.

Серверная авторизация ([Authorize(Roles=...)]) — основной слой защиты;
sidebar+RoleGuard — UX-слой.
This commit is contained in:
nns 2026-05-06 11:29:05 +05:00
parent e8a28ba1f6
commit f824e38959
3 changed files with 129 additions and 54 deletions

View file

@ -35,6 +35,7 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
import { RoleGuard } from '@/components/RoleGuard'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -84,9 +85,9 @@ export default function App() {
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} /> <Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} /> <Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
<Route path="/catalog/stores" element={<StoresPage />} /> <Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}><StoresPage /></RoleGuard>} />
<Route path="/catalog/retail-points" element={<RetailPointsPage />} /> <Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}><RetailPointsPage /></RoleGuard>} />
<Route path="/inventory/stock" element={<StockPage />} /> <Route path="/inventory/stock" element={<StockPage />} />
<Route path="/inventory/movements" element={<StockMovementsPage />} /> <Route path="/inventory/movements" element={<StockMovementsPage />} />
<Route path="/purchases/supplies" element={<SuppliesPage />} /> <Route path="/purchases/supplies" element={<SuppliesPage />} />
@ -95,10 +96,10 @@ export default function App() {
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} /> <Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
<Route path="/settings/organization" element={<OrganizationSettingsPage />} /> <Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
<Route path="/settings/employees" element={<EmployeesPage />} /> <Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />
<Route path="/settings/employee-roles" element={<EmployeeRolesPage />} /> <Route path="/settings/employee-roles" element={<RoleGuard roles={['Admin']}><EmployeeRolesPage /></RoleGuard>} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -39,53 +39,92 @@ function translateRoles(roles: string[]): string {
.join(', ') .join(', ')
} }
function buildNav(isSuperAdmin: boolean): NavSection[] { /** Меню зависит от ролей пользователя. Кассир/Кладовщик НЕ видят
const catalog: NavItem[] = [ * раздел «Настройки организации» (там Сотрудники/Роли/Склады/Кассы
{ to: '/catalog/products', icon: Package, label: 'Товары' }, * это admin-only). Остальные разделы фильтруются по системной роли:
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, * Кассир работает на кассе и видит товары/остатки + продажи; Кладовщик
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, * приёмки + остатки. Администратор и SuperAdmin (override) видят всё.
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' }, *
] * Источник правды для прав серверные `[Authorize(Roles = ...)]` на
return [ * каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать
{ group: 'Главное', items: [ * неработающие пункты. */
{ to: '/', icon: LayoutDashboard, label: 'Главная', end: true }, function buildNav(roles: string[]): NavSection[] {
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' }, const isSuperAdmin = roles.includes('SuperAdmin')
]}, const isAdmin = roles.includes('Admin') || isSuperAdmin
{ group: 'Каталог', items: catalog }, const isCashier = roles.includes('Cashier')
{ group: 'Контрагенты', items: [ const isStorekeeper = roles.includes('Storekeeper')
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
]}, const sections: NavSection[] = [
{ group: 'Остатки', items: [ { group: 'Главное', items: [
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' }, { to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
{ to: '/inventory/movements', icon: History, label: 'Движения' }, { to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
]}, ]},
{ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]},
{ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
]},
// Справочники типа «Страны» — глобальные, управляются SuperAdmin'ом
// в системной консоли. В tenant-меню их больше нет.
{ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]},
{ group: 'Настройки организации', items: [
{ to: '/settings/organization', icon: Settings, label: 'Общие' },
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]},
...(isSuperAdmin ? [{
// Только одна точка возврата — система. Системный sidebar отдельно
// в SuperAdminLayout. В tenant-меню здесь — только переход в консоль.
group: 'Супер-админ',
items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true },
],
}] : []),
] ]
// Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — admin.
if (isAdmin || isCashier || isStorekeeper) {
const catalog: NavItem[] = [{ to: '/catalog/products', icon: Package, label: 'Товары' }]
if (isAdmin) {
catalog.push(
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
)
}
sections.push({ group: 'Каталог', items: catalog })
}
if (isAdmin) {
sections.push({ group: 'Контрагенты', items: [
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
]})
}
// Остатки видят все три tenant-роли.
if (isAdmin || isCashier || isStorekeeper) {
sections.push({ group: 'Остатки', items: [
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
...(isAdmin || isStorekeeper ? [{ to: '/inventory/movements', icon: History, label: 'Движения' }] : []),
]})
}
// Закупки — Admin и Storekeeper.
if (isAdmin || isStorekeeper) {
sections.push({ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]})
}
// Продажи — Admin и Cashier.
if (isAdmin || isCashier) {
sections.push({ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
]})
}
if (isAdmin) {
sections.push({ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]})
sections.push({ group: 'Настройки организации', items: [
{ to: '/settings/organization', icon: Settings, label: 'Общие' },
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]})
}
if (isSuperAdmin) {
sections.push({
group: 'Супер-админ',
items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true },
],
})
}
return sections
} }
export function AppLayout() { export function AppLayout() {
@ -96,7 +135,7 @@ export function AppLayout() {
}) })
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
const nav = buildNav(isSuperAdmin) const nav = buildNav(me?.roles ?? [])
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
// даёт периферийный сигнал «я не в своей админке». // даёт периферийный сигнал «я не в своей админке».
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')

View file

@ -0,0 +1,35 @@
import { useMe } from '@/lib/useMe'
interface Props {
/** Список ролей, любая из которых открывает доступ. SuperAdmin всегда проходит. */
roles: ('Admin' | 'Cashier' | 'Storekeeper' | 'SuperAdmin')[]
children: React.ReactNode
}
/** Render-guard: если у текущего юзера нет одной из указанных ролей
* показываем сообщение «нет доступа» вместо контента страницы.
*
* Не подменяет серверную авторизацию (см. [Authorize(Roles = ...)] на
* controller endpoint-ах), это только UX-слой: чтобы юзер на /employees
* без прав не видел крутящееся колесо + 403 от каждого запроса, а сразу
* понимал что страница ему не положена. */
export function RoleGuard({ roles, children }: Props) {
const me = useMe()
if (me.isLoading) return null
const userRoles = me.data?.roles ?? []
const allowed = userRoles.includes('SuperAdmin') || userRoles.some((r) => (roles as string[]).includes(r))
if (!allowed) {
return (
<div className="min-h-[60vh] flex items-center justify-center px-4">
<div className="max-w-md text-center space-y-3">
<h1 className="text-2xl font-bold">Нет доступа</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Этот раздел доступен только администраторам организации.
Если вам нужен доступ обратитесь к администратору.
</p>
</div>
</div>
)
}
return <>{children}</>
}