feat(super-admin): Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки

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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:13:47 +05:00
parent 6f61bbd974
commit 8ae9f68119
6 changed files with 287 additions and 13 deletions

View file

@ -28,6 +28,8 @@ import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { SuperAdminLayout } from '@/components/SuperAdminLayout'
import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -46,7 +48,20 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}> {/* SuperAdmin консоль отдельный layout c индиго-сайдбаром,
* системными разделами и быстрым «Открыть организацию» в topbar.
* Setup wizard вне layout'а full-screen onboarding. */}
<Route path="/super-admin/setup" element={<SuperAdminSetupPage />} />
<Route path="/super-admin" element={<SuperAdminLayout />}>
<Route index element={<SuperAdminDashboardPage />} />
<Route path="organizations" element={<SuperAdminOrganizationsPage />} />
<Route path="organizations/new" element={<SuperAdminOrgCreatePage />} />
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
</Route>
{/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard:
* SuperAdmin без активного override редирект на /super-admin/organizations. */}
<Route element={<TenantRouteGuard><AppLayout /></TenantRouteGuard>}>
<Route path="/" element={<OnboardingPage />} /> <Route path="/" element={<OnboardingPage />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/catalog/products" element={<ProductsPage />} /> <Route path="/catalog/products" element={<ProductsPage />} />
@ -71,11 +86,6 @@ export default function App() {
<Route path="/settings/organization" element={<OrganizationSettingsPage />} /> <Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/employees" element={<EmployeesPage />} /> <Route path="/settings/employees" element={<EmployeesPage />} />
<Route path="/settings/employee-roles" element={<EmployeeRolesPage />} /> <Route path="/settings/employee-roles" element={<EmployeeRolesPage />} />
<Route path="/super-admin" element={<SuperAdminDashboardPage />} />
<Route path="/super-admin/setup" element={<SuperAdminSetupPage />} />
<Route path="/super-admin/organizations" element={<SuperAdminOrganizationsPage />} />
<Route path="/super-admin/organizations/new" element={<SuperAdminOrgCreatePage />} />
<Route path="/super-admin/audit-log" element={<SuperAdminAuditLogPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -6,7 +6,7 @@ import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Tag, 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, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
@ -63,11 +63,11 @@ function buildNav(isSuperAdmin: boolean): NavSection[] {
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, { to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]}, ]},
...(isSuperAdmin ? [{ ...(isSuperAdmin ? [{
// Только одна точка возврата — система. Системный sidebar отдельно
// в SuperAdminLayout. В tenant-меню здесь — только переход в консоль.
group: 'Супер-админ', group: 'Супер-админ',
items: [ items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Консоль', end: true }, { to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true },
{ to: '/super-admin/organizations', icon: Building, label: 'Организации' },
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал' },
], ],
}] : []), }] : []),
] ]
@ -82,6 +82,15 @@ export function AppLayout() {
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
const nav = buildNav(isSuperAdmin) 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 зашёл, а в системе ноль организаций — выкидываем // Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем
// на setup wizard (нельзя пропустить, пока не создадут первую орг). // на setup wizard (нельзя пропустить, пока не создадут первую орг).
const location2 = useLocation() const location2 = useLocation()
@ -153,7 +162,10 @@ export function AppLayout() {
) )
return ( return (
<div className="h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950 overflow-hidden"> <div className={cn(
'h-screen flex flex-col md:flex-row overflow-hidden',
inOverride ? 'bg-amber-50/60 dark:bg-amber-950/20' : 'bg-slate-50 dark:bg-slate-950',
)}>
{/* Mobile header with hamburger — только на узких экранах. */} {/* Mobile header with hamburger — только на узких экранах. */}
<header className="md:hidden h-12 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex-shrink-0"> <header className="md:hidden h-12 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex-shrink-0">
<button <button

View file

@ -0,0 +1,186 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload,
Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown,
} from 'lucide-react'
import { api, getOrgOverride, setOrgOverride } from '@/lib/api'
import { logout } from '@/lib/auth'
import { useMe } from '@/lib/useMe'
import { cn } from '@/lib/utils'
import { Logo } from './Logo'
type NavItem = { to: string; icon: typeof ShieldCheck; label: string; end?: boolean; soon?: boolean }
type NavSection = { group: string; items: NavItem[] }
const NAV: NavSection[] = [
{ group: 'Система', items: [
{ to: '/super-admin', icon: LayoutDashboard, label: 'Главная', end: true },
{ to: '/super-admin/organizations', icon: Building, label: 'Организации' },
{ to: '/super-admin/users', icon: Users, label: 'Пользователи', soon: true },
]},
{ group: 'Аудит', items: [
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' },
]},
{ group: 'Тех. обслуживание', items: [
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
{ to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true },
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки', soon: true },
]},
]
interface OrgRow { id: string; name: string; isArchived: boolean }
export function SuperAdminLayout() {
const me = useMe()
const navigate = useNavigate()
const location = useLocation()
const [drawerOpen, setDrawerOpen] = useState(false)
const [orgPickerOpen, setOrgPickerOpen] = useState(false)
// Title-suffix « · Super Admin» — чтобы во вкладках браузера было ясно где
// находишься. Сбрасывается при выходе из layout'а.
useEffect(() => {
const prev = document.title
if (!document.title.endsWith(' · Super Admin')) {
document.title = `${prev.replace(/ · Super Admin$/, '')} · Super Admin`
}
return () => { document.title = prev }
}, [location.pathname])
// Если у юзера активен override — это режим «открыто как…», SuperAdmin
// должен видеть tenant-layout, а не системную консоль. Перебрасываем.
useEffect(() => {
if (getOrgOverride()) navigate('/dashboard', { replace: true })
}, [navigate, location.pathname])
// Закрывать drawer при смене маршрута.
useEffect(() => { setDrawerOpen(false) }, [location.pathname])
const orgs = useQuery({
queryKey: ['super-admin:orgs:picker'],
queryFn: async () => (await api.get<{ items: OrgRow[] }>('/api/super-admin/organizations?pageSize=200&archived=false')).data.items,
staleTime: 30_000,
enabled: orgPickerOpen,
})
const sidebar = (
<>
<div className="px-5 py-4 border-b border-indigo-900/40">
<div className="flex items-center justify-between">
<Logo />
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-200 font-semibold">
Super
</span>
</div>
<p className="text-[11px] text-indigo-300 mt-1">Системная консоль</p>
</div>
<nav className="flex-1 overflow-y-auto py-3 space-y-4">
{NAV.map((section) => (
<div key={section.group}>
<div className="px-5 mb-1.5 text-[10px] uppercase tracking-wider text-indigo-300/70 font-semibold">{section.group}</div>
<div className="space-y-0.5">
{section.items.map((it) => (
<NavLink
key={it.to}
to={it.to}
end={it.end}
onClick={(e) => { if (it.soon) { e.preventDefault() } }}
className={({ isActive }) => cn(
'flex items-center gap-2.5 mx-2 px-3 py-1.5 rounded-md text-sm',
it.soon
? 'text-indigo-300/40 cursor-not-allowed'
: isActive
? 'bg-indigo-500/20 text-white font-medium'
: 'text-indigo-100/80 hover:bg-indigo-500/10 hover:text-white',
)}
>
<it.icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{it.label}</span>
{it.soon && <span className="ml-auto text-[10px] uppercase opacity-70">скоро</span>}
</NavLink>
))}
</div>
</div>
))}
</nav>
<div className="border-t border-indigo-900/40 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<ShieldCheck className="w-4 h-4 text-indigo-300" />
<div className="min-w-0">
<div className="text-sm text-white font-medium truncate">{me.data?.name ?? me.data?.email}</div>
<div className="text-[11px] text-indigo-300">Super Admin</div>
</div>
</div>
<button
onClick={() => { logout(); window.location.href = '/login' }}
className="w-full flex items-center justify-center gap-1.5 text-xs text-indigo-200 hover:text-white border border-indigo-500/30 hover:bg-indigo-500/10 rounded-md py-1.5"
>
<LogOut className="w-3.5 h-3.5" /> Выйти
</button>
</div>
</>
)
return (
<div className="h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950 overflow-hidden">
<header className="md:hidden h-12 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-800 bg-indigo-950 text-white flex-shrink-0">
<button onClick={() => setDrawerOpen(true)}><Menu className="w-6 h-6" /></button>
<div className="flex items-center gap-2">
<Logo />
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/30 text-indigo-100">Super</span>
</div>
</header>
<aside className="hidden md:flex w-64 flex-shrink-0 bg-indigo-950 text-white flex-col h-full">
{sidebar}
</aside>
{drawerOpen && (
<div className="md:hidden fixed inset-0 z-40">
<div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" onClick={() => setDrawerOpen(false)} />
<aside className="absolute left-0 top-0 bottom-0 w-72 max-w-[85vw] bg-indigo-950 text-white flex flex-col shadow-xl">
<div className="flex justify-end p-2"><button onClick={() => setDrawerOpen(false)}><X className="w-5 h-5" /></button></div>
{sidebar}
</aside>
</div>
)}
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
{/* Topbar системной консоли с быстрым переключателем «Открыть как…» */}
<div className="h-11 flex items-center justify-between gap-3 px-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex-shrink-0">
<div className="text-xs text-slate-500">
<span className="font-semibold text-indigo-700 dark:text-indigo-300">Системная консоль</span>
<span className="mx-1.5">·</span>
<span>Super Admin</span>
</div>
<div className="relative">
<button
onClick={() => setOrgPickerOpen((o) => !o)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800"
>
Открыть организацию <ChevronDown className="w-3 h-3" />
</button>
{orgPickerOpen && (
<div className="absolute right-0 top-full mt-1 w-72 max-h-80 overflow-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-md shadow-lg z-50">
{orgs.isLoading && <div className="px-3 py-2 text-sm text-slate-400">Загрузка</div>}
{orgs.data?.length === 0 && <div className="px-3 py-2 text-sm text-slate-400">Нет организаций</div>}
{orgs.data?.map((o) => (
<button
key={o.id}
onClick={() => { setOrgPickerOpen(false); setOrgOverride({ id: o.id, name: o.name }) }}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 truncate"
>
{o.name}
</button>
))}
</div>
)}
</div>
</div>
<Outlet />
</main>
</div>
)
}

View file

@ -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}</>
}

View file

@ -1,8 +1,11 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { login } from '@/lib/auth' import { login } from '@/lib/auth'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo' import { Logo } from '@/components/Logo'
interface MeResp { roles: string[] }
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@ -19,7 +22,15 @@ export function LoginPage() {
setError(null) setError(null)
try { try {
await login(email, password) await login(email, password)
navigate(from, { replace: true }) // SuperAdmin → системная консоль; обычные tenant-юзеры → tenant landing.
let target = from
try {
const me = (await api.get<MeResp>('/api/me')).data
if (me.roles?.includes('SuperAdmin') && (from === '/' || from === '/dashboard')) {
target = '/super-admin'
}
} catch { /* ignore — fallback на from */ }
navigate(target, { replace: true })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа') setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally { } finally {

View file

@ -10,6 +10,11 @@ interface DashboardStats {
totalProducts: number; totalSuppliesThisMonth: number totalProducts: number; totalSuppliesThisMonth: number
} }
interface AuditRow {
id: string; createdAt: string; actionType: string
organizationName: string | null; description: string | null
}
const fmt = new Intl.NumberFormat('ru') const fmt = new Intl.NumberFormat('ru')
function Kpi({ icon: Icon, label, value, hint }: { function Kpi({ icon: Icon, label, value, hint }: {
@ -34,6 +39,10 @@ export function SuperAdminDashboardPage() {
queryKey: ['/api/super-admin/dashboard'], queryKey: ['/api/super-admin/dashboard'],
queryFn: async () => (await api.get<DashboardStats>('/api/super-admin/dashboard')).data, queryFn: async () => (await api.get<DashboardStats>('/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 ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-5"> <div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-5">
@ -69,11 +78,34 @@ export function SuperAdminDashboardPage() {
</div> </div>
</Link> </Link>
</div> </div>
<div> <div className="flex items-center gap-3">
<Link to="/super-admin/organizations/new" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-[var(--color-brand)] text-white text-sm font-medium hover:bg-[var(--color-brand-hover)]"> <Link to="/super-admin/organizations/new" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-[var(--color-brand)] text-white text-sm font-medium hover:bg-[var(--color-brand-hover)]">
<Plus className="w-4 h-4" /> Создать организацию <Plus className="w-4 h-4" /> Создать организацию
</Link> </Link>
</div> </div>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">Последние события</h3>
<Link to="/super-admin/audit-log" className="text-xs text-[var(--color-brand)] hover:underline">Весь журнал </Link>
</div>
{audit.data?.length === 0 ? (
<div className="text-sm text-slate-400">Пока нет записей.</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{audit.data?.map((r) => (
<li key={r.id} className="py-2 flex items-start gap-3 text-sm">
<span className="text-xs text-slate-400 w-32 flex-shrink-0">{new Date(r.createdAt).toLocaleString('ru')}</span>
<span className="text-xs font-mono text-slate-500 w-28 flex-shrink-0">{r.actionType}</span>
<span className="text-slate-700 dark:text-slate-300 truncate">
{r.organizationName && <span className="font-medium mr-1">«{r.organizationName}»</span>}
<span className="text-slate-500">{r.description}</span>
</span>
</li>
))}
</ul>
)}
</section>
</div> </div>
</div> </div>
) )