From 8ae9f6811975303997bdc4a5cb30a1bd86f65724 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:13:47 +0500 Subject: [PATCH] =?UTF-8?q?feat(super-admin):=20Phase=202b=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20SuperA?= =?UTF-8?q?dminLayout=20=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=20tenant-=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuperAdmin при логине больше не попадает на tenant Dashboard (Каталог/Товары/Контрагенты/Выручка) — он стоит над всеми органами, у него отдельная Системная консоль с системным sidebar и системным дашбордом. Каждый из четырёх кейсов теперь визуально различим: 1. SuperAdmin → /super-admin (индиго-сайдбар, системные разделы: Главная / Организации / Пользователи (скоро) / Журнал / Здоровье/Бэкапы/Настройки (скоро)). В topbar — компактный «Открыть организацию ▼» dropdown для быстрого override. Title-suffix « · Super Admin» в browser-tab. 2. Обычный tenant-админ → /dashboard (как раньше). 3. SuperAdmin в режиме «открыть как…» → /dashboard с tenant sidebar'ом + amber-баннер сверху + слабая жёлтая тонировка фона body чтобы периферийным зрением было ясно «не моя админка». 4. SuperAdmin без override руками вбивает /products → TenantRouteGuard редиректит на /super-admin/organizations + alert «Откройте конкретную организацию через "Открыть как…"». Изменения: - SuperAdminLayout.tsx: индиго-палитра, Logo + badge «Super» + подпись «Системная консоль», 7 пунктов меню (5 active + 4 «скоро»), user-карточка с «Super Admin», topbar с org-picker dropdown, redirect на /dashboard если активен override. - TenantRouteGuard.tsx: в useEffect проверяет (SuperAdmin && !override) и редиректит. Сообщение в sessionStorage для AppLayout, alert один раз. - App.tsx: setup wizard вне layout'а; /super-admin/* через SuperAdminLayout (nested routes); остальное через TenantRouteGuard + AppLayout. - AppLayout: убрана 3-пунктовая группа «Супер-админ» — оставил один пункт «Системная консоль» как точку возврата. Тонировка фона amber-50/60 при override. - LoginPage: после успешного login — если me.roles.SuperAdmin и target это /, /dashboard → redirect на /super-admin. - SuperAdminDashboardPage: блок «Последние события» — 10 записей audit-log + ссылка на полный журнал. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/food-market.web/src/App.tsx | 22 ++- .../src/components/AppLayout.tsx | 22 ++- .../src/components/SuperAdminLayout.tsx | 186 ++++++++++++++++++ .../src/components/TenantRouteGuard.tsx | 23 +++ src/food-market.web/src/pages/LoginPage.tsx | 13 +- .../src/pages/SuperAdminDashboardPage.tsx | 34 +++- 6 files changed, 287 insertions(+), 13 deletions(-) create mode 100644 src/food-market.web/src/components/SuperAdminLayout.tsx create mode 100644 src/food-market.web/src/components/TenantRouteGuard.tsx diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 40a3d4a..a42ed0d 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -28,6 +28,8 @@ import { SupplyEditPage } from '@/pages/SupplyEditPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' +import { SuperAdminLayout } from '@/components/SuperAdminLayout' +import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { ProtectedRoute } from '@/components/ProtectedRoute' const queryClient = new QueryClient({ @@ -46,7 +48,20 @@ export default function App() { } /> }> - }> + {/* SuperAdmin консоль — отдельный layout c индиго-сайдбаром, + * системными разделами и быстрым «Открыть организацию» в topbar. + * Setup wizard вне layout'а — full-screen onboarding. */} + } /> + }> + } /> + } /> + } /> + } /> + + + {/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard: + * SuperAdmin без активного override → редирект на /super-admin/organizations. */} + }> } /> } /> } /> @@ -71,11 +86,6 @@ 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 533d67c..376ca30 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -6,7 +6,7 @@ import { logout } from '@/lib/auth' import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Tag, - Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield, ShieldCheck, Building, FileClock, + Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield, ShieldCheck, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, } from 'lucide-react' import { Logo } from './Logo' @@ -63,11 +63,11 @@ function buildNav(isSuperAdmin: boolean): NavSection[] { { to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, ]}, ...(isSuperAdmin ? [{ + // Только одна точка возврата — система. Системный sidebar отдельно + // в SuperAdminLayout. В tenant-меню здесь — только переход в консоль. group: 'Супер-админ', items: [ - { to: '/super-admin', icon: ShieldCheck, label: 'Консоль', end: true }, - { to: '/super-admin/organizations', icon: Building, label: 'Организации' }, - { to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал' }, + { to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true }, ], }] : []), ] @@ -82,6 +82,15 @@ export function AppLayout() { const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') const nav = buildNav(isSuperAdmin) + // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области + // даёт периферийный сигнал «я не в своей админке». + const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') + // Toast на guard-message (TenantRouteGuard кладёт сообщение в sessionStorage + // перед редиректом — показываем разово). + useEffect(() => { + const msg = sessionStorage.getItem('tenant-guard-msg') + if (msg) { sessionStorage.removeItem('tenant-guard-msg'); alert(msg) } + }, []) // Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем // на setup wizard (нельзя пропустить, пока не создадут первую орг). const location2 = useLocation() @@ -153,7 +162,10 @@ export function AppLayout() { ) return ( -
+
{/* Mobile header with hamburger — только на узких экранах. */}
+
+ + ) + + return ( +
+
+ +
+ + Super +
+
+ + + + {drawerOpen && ( +
+
setDrawerOpen(false)} /> + +
+ )} + +
+ {/* Topbar системной консоли с быстрым переключателем «Открыть как…» */} +
+
+ Системная консоль + · + Super Admin +
+
+ + {orgPickerOpen && ( +
+ {orgs.isLoading &&
Загрузка…
} + {orgs.data?.length === 0 &&
Нет организаций
} + {orgs.data?.map((o) => ( + + ))} +
+ )} +
+
+ +
+
+ ) +} diff --git a/src/food-market.web/src/components/TenantRouteGuard.tsx b/src/food-market.web/src/components/TenantRouteGuard.tsx new file mode 100644 index 0000000..bd66079 --- /dev/null +++ b/src/food-market.web/src/components/TenantRouteGuard.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useMe } from '@/lib/useMe' +import { getOrgOverride } from '@/lib/api' + +/** Не пускает SuperAdmin'а в tenant-роуты без активного override — + * SuperAdmin сам по себе не сотрудник конкретной орги, любая tenant-страница + * для него бессмысленна. Если override не активен — редирект на + * /super-admin/organizations с alert'ом «Откройте организацию через "Открыть как…"». */ +export function TenantRouteGuard({ children }: { children: React.ReactNode }) { + const me = useMe() + const navigate = useNavigate() + useEffect(() => { + if (!me.data) return + const isSuper = me.data.roles?.includes('SuperAdmin') + if (isSuper && !getOrgOverride()) { + // Маленькое уведомление перед редиректом — alert удобнее тоста для разовой ситуации. + try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ } + navigate('/super-admin/organizations', { replace: true }) + } + }, [me.data, navigate]) + return <>{children} +} diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index 0ed2c99..842c0e9 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -1,8 +1,11 @@ import { useState, type FormEvent } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { login } from '@/lib/auth' +import { api } from '@/lib/api' import { Logo } from '@/components/Logo' +interface MeResp { roles: string[] } + export function LoginPage() { const navigate = useNavigate() const location = useLocation() @@ -19,7 +22,15 @@ export function LoginPage() { setError(null) try { await login(email, password) - navigate(from, { replace: true }) + // SuperAdmin → системная консоль; обычные tenant-юзеры → tenant landing. + let target = from + try { + const me = (await api.get('/api/me')).data + if (me.roles?.includes('SuperAdmin') && (from === '/' || from === '/dashboard')) { + target = '/super-admin' + } + } catch { /* ignore — fallback на from */ } + navigate(target, { replace: true }) } catch (err) { setError(err instanceof Error ? err.message : 'Ошибка входа') } finally { diff --git a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx index c71ee6a..26e4ee1 100644 --- a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx @@ -10,6 +10,11 @@ interface DashboardStats { totalProducts: number; totalSuppliesThisMonth: number } +interface AuditRow { + id: string; createdAt: string; actionType: string + organizationName: string | null; description: string | null +} + const fmt = new Intl.NumberFormat('ru') function Kpi({ icon: Icon, label, value, hint }: { @@ -34,6 +39,10 @@ export function SuperAdminDashboardPage() { queryKey: ['/api/super-admin/dashboard'], queryFn: async () => (await api.get('/api/super-admin/dashboard')).data, }) + const audit = useQuery({ + queryKey: ['/api/super-admin/audit-log', 'recent'], + queryFn: async () => (await api.get<{ items: AuditRow[] }>('/api/super-admin/audit-log?pageSize=10')).data.items, + }) return (
@@ -69,11 +78,34 @@ export function SuperAdminDashboardPage() {
-
+
Создать организацию
+ +
+
+

Последние события

+ Весь журнал → +
+ {audit.data?.length === 0 ? ( +
Пока нет записей.
+ ) : ( +
    + {audit.data?.map((r) => ( +
  • + {new Date(r.createdAt).toLocaleString('ru')} + {r.actionType} + + {r.organizationName && «{r.organizationName}»} + {r.description} + +
  • + ))} +
+ )} +
)