diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 607e5d1..035473e 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -35,6 +35,7 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { ProtectedRoute } from '@/components/ProtectedRoute' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' +import { RoleGuard } from '@/components/RoleGuard' const queryClient = new QueryClient({ defaultOptions: { @@ -84,9 +85,9 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> @@ -95,10 +96,10 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index d1d1a4d..2f39b5e 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -39,53 +39,92 @@ function translateRoles(roles: string[]): string { .join(', ') } -function buildNav(isSuperAdmin: boolean): NavSection[] { - const catalog: NavItem[] = [ - { to: '/catalog/products', icon: Package, label: 'Товары' }, - { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, - { to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, - { to: '/catalog/price-types', icon: Tag, label: 'Типы цен' }, - ] - return [ - { group: 'Главное', items: [ - { to: '/', icon: LayoutDashboard, label: 'Главная', end: true }, - { to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' }, - ]}, - { group: 'Каталог', items: catalog }, - { group: 'Контрагенты', items: [ - { to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' }, - ]}, - { group: 'Остатки', items: [ - { to: '/inventory/stock', icon: Boxes, label: 'Остатки' }, - { to: '/inventory/movements', icon: History, 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-only). Остальные разделы фильтруются по системной роли: + * Кассир работает на кассе и видит товары/остатки + продажи; Кладовщик + * — приёмки + остатки. Администратор и SuperAdmin (override) видят всё. + * + * Источник правды для прав — серверные `[Authorize(Roles = ...)]` на + * каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать + * неработающие пункты. */ +function buildNav(roles: string[]): NavSection[] { + const isSuperAdmin = roles.includes('SuperAdmin') + const isAdmin = roles.includes('Admin') || isSuperAdmin + const isCashier = roles.includes('Cashier') + const isStorekeeper = roles.includes('Storekeeper') + + const sections: NavSection[] = [ + { group: 'Главное', items: [ + { to: '/', icon: LayoutDashboard, label: 'Главная', end: true }, + { to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' }, + ]}, ] + + // Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — 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() { @@ -96,7 +135,7 @@ export function AppLayout() { }) const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') - const nav = buildNav(isSuperAdmin) + const nav = buildNav(me?.roles ?? []) // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области // даёт периферийный сигнал «я не в своей админке». const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') diff --git a/src/food-market.web/src/components/RoleGuard.tsx b/src/food-market.web/src/components/RoleGuard.tsx new file mode 100644 index 0000000..b0eee25 --- /dev/null +++ b/src/food-market.web/src/components/RoleGuard.tsx @@ -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 ( +
+
+

Нет доступа

+

+ Этот раздел доступен только администраторам организации. + Если вам нужен доступ — обратитесь к администратору. +

+
+
+ ) + } + return <>{children} +}