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:
parent
e8a28ba1f6
commit
f824e38959
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
35
src/food-market.web/src/components/RoleGuard.tsx
Normal file
35
src/food-market.web/src/components/RoleGuard.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue