feat(super-admin): Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 40s
Docker Web / Build + push Web (push) Successful in 31s
Docker Web / Deploy Web on stage (push) Successful in 12s

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 7363eb4249
commit 17be1c83b2
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 { 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() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<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="/dashboard" element={<DashboardPage />} />
<Route path="/catalog/products" element={<ProductsPage />} />
@ -71,11 +86,6 @@ export default function App() {
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/employees" element={<EmployeesPage />} />
<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 path="*" element={<Navigate to="/" replace />} />

View file

@ -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 (
<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 — только на узких экранах. */}
<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

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 { 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<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) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {

View file

@ -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<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 (
<div className="h-full overflow-auto">
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-5">
@ -69,11 +78,34 @@ export function SuperAdminDashboardPage() {
</div>
</Link>
</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)]">
<Plus className="w-4 h-4" /> Создать организацию
</Link>
</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>
)