feat(super-admin): Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки
Some checks are pending
Some checks are pending
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:
parent
7363eb4249
commit
17be1c83b2
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
186
src/food-market.web/src/components/SuperAdminLayout.tsx
Normal file
186
src/food-market.web/src/components/SuperAdminLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/food-market.web/src/components/TenantRouteGuard.tsx
Normal file
23
src/food-market.web/src/components/TenantRouteGuard.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue