import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' import { useNavigate } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { AppWindowIcon, FingerprintIcon, HistoryIcon, KeyRoundIcon, LayoutGridIcon, LogInIcon, PlusIcon, UsersIcon, } from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome' import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context' import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store' import { auditQueryOptions } from '@/queries/audit' import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth' import { oidcClientsQueryOptions } from '@/queries/oidc' import { sessionsQueryOptions } from '@/queries/sessions' import { Skeleton } from '@authportal/ui/components/skeleton' import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar' import { TooltipProvider } from '@authportal/ui/components/tooltip' const SKIP_TO_CONTENT_CLASS = 'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2' const FRESH: Record = { '/apps': { badge: 'Портал', text: 'Права из базы портала, а не из токена · SSO открывает приложение с текущей сессией' }, '/account': { badge: 'WebAuthn', text: 'Passkeys привязаны к домену портала · пароль остаётся запасным входом' }, '/admin/logins': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=logins · хранение по настройке ротации журнала' }, '/admin/audit': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=changes · события портала и приложений через ingest' }, '/admin/oidc': { badge: 'OIDC', text: 'Discovery и JWKS публикует портал · секрет клиента показывается один раз' }, '/admin/apps': { badge: 'Портал', text: 'portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' }, '/admin': { badge: 'Портал', text: 'GET /api/v1/admin/users · права на приложения и разделы' }, } function freshFor(pathname: string) { const key = Object.keys(FRESH) .sort((a, b) => b.length - a.length) .find((k) => pathname === k || pathname.startsWith(`${k}/`)) return FRESH[key ?? '/apps']! } function initialsOf(name: string, email: string) { const src = (name || email).trim() const parts = src.split(/[\s@._-]+/).filter(Boolean) return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase() } /** Меню портала: «Портал» и «Админ» (для администраторов). Точки проблем приходят из данных. */ function useChromeData(): ChromeConfig { const navigate = useNavigate() const meQ = useQuery(meQueryOptions) const isAdmin = Boolean(meQ.data?.is_admin) const catalogQ = useQuery(catalogQueryOptions) const usersQ = useQuery({ ...usersQueryOptions, enabled: isAdmin, retry: false }) const oidcQ = useQuery({ ...oidcClientsQueryOptions, enabled: isAdmin, retry: false }) const sessionsQ = useQuery({ ...sessionsQueryOptions(), enabled: isAdmin, retry: false }) const loginsQ = useQuery({ ...auditQueryOptions({ kind: 'logins', limit: 100 }), enabled: isAdmin, retry: false }) return useMemo(() => { const users = usersQ.data ?? [] const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0) const clients = oidcQ.data ?? [] const hourAgo = loginsQ.dataUpdatedAt - 60 * 60 * 1000 const failed = (loginsQ.data ?? []).filter((e) => e.action === 'auth.login_failed' && new Date(e.created_at).getTime() >= hourAgo) const navItems: NavItem[] = [ { id: 'apps', label: 'Приложения', to: '/apps', icon: LayoutGridIcon, group: 'Портал' }, { id: 'account', label: 'Безопасность', to: '/account', icon: FingerprintIcon, group: 'Портал' }, ] if (isAdmin) { navItems.push( { id: 'users', label: 'Пользователи', to: '/admin', icon: UsersIcon, group: 'Админ' }, { id: 'logins', label: 'Входы', to: '/admin/logins', icon: LogInIcon, group: 'Админ', problem: failed.length >= 5 ? { tone: 'er', label: `${failed.length} неудачных входов за час` } : undefined, }, { id: 'audit', label: 'Изменения', to: '/admin/audit', icon: HistoryIcon, group: 'Админ' }, { id: 'oidc', label: 'OIDC-клиенты', to: '/admin/oidc', icon: KeyRoundIcon, group: 'Админ' }, { id: 'app-links', label: 'Ссылки приложений', to: '/admin/apps', icon: AppWindowIcon, group: 'Админ' }, ) } const monitorItems: MonitorItem[] = [] if (failed.length >= 5) { monitorItems.push({ id: 'bruteforce', tone: 'er', title: `Неудачных входов за час: ${failed.length}`, text: 'Проверьте журнал входов и при необходимости отключите учётную запись', age: 'час', to: '/admin/logins', }) } if (noPasskey.length > 0) { monitorItems.push({ id: 'nopasskey', tone: 'wn', title: `${noPasskey.length} без passkey`, text: noPasskey.slice(0, 3).map((u) => u.email.split('@')[0]).join(', ') + ' · вход только по паролю', age: 'сейчас', to: '/admin', }) } monitorItems.push({ id: 'api', tone: 'ok', title: 'API портала', text: 'GET /ready', age: 'сейчас', to: '/apps' }) const enabledClients = clients.filter((c) => c.enabled).length const metrics: MonitorMetric[] = isAdmin ? [ { l: 'Пользователи без passkey', v: `${noPasskey.length} из ${users.length}`, p: users.length ? Math.round((noPasskey.length / users.length) * 100) : 0, tone: noPasskey.length ? 'warning' : 'success' }, { l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' }, { l: 'OIDC-клиенты', v: `${enabledClients} из ${clients.length}`, p: clients.length ? Math.round((enabledClients / clients.length) * 100) : 100, tone: enabledClients === clients.length ? 'success' : 'warning' }, { l: 'Сессии', v: String(sessionsQ.data?.length ?? 0), p: Math.min(100, (sessionsQ.data?.length ?? 0) * 10), tone: 'success' }, ] : [{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' }] const objects: PaletteObject[] = [ ...(isAdmin ? users.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.disabled ? 'Отключён' : u.is_admin ? 'Админ' : 'Пользователь', dot: u.disabled ? 'mu' : u.passkey_count === 0 ? 'er' : 'ok', to: `/admin/users/${u.id}`, })) : []), ...(catalogQ.data?.apps ?? []).map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })), ...(isAdmin ? clients.map((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: `client_id ${c.client_id}`, dot: 'mu', to: '/admin/oidc' })) : []), ] const me = meQ.data return { navItems, navGroups: isAdmin ? ['Портал', 'Админ'] : ['Портал'], brand: , user: { name: me?.name ?? '…', email: me?.email ?? '', initials: initialsOf(me?.name ?? '', me?.email ?? '') }, securityTo: '/account', onLogout: () => void navigate({ to: '/logout' }), fresh: freshFor, monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: isAdmin ? '/admin/logins' : '/apps', footerLabel: isAdmin ? 'Журнал входов' : 'Приложения' }, palette: { actions: isAdmin ? [ { id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, to: '/admin' }, { id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, to: '/admin/oidc' }, { id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' }, { id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: LogInIcon, to: '/admin/logins' }, ] : [{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' }], objects, assistantTo: isAdmin ? '/admin/logins' : '/apps', example: 'Подсказка: «неудачные входы ops за сутки»', }, } }, [navigate, meQ.data, isAdmin, catalogQ.data, usersQ.data, oidcQ.data, sessionsQ.data, loginsQ.data, loginsQ.dataUpdatedAt]) } /** Оболочка портала: боковое меню, шапка, палитра ⌘K. Макет — `design-preview` на тех же блоках. */ export function PortalShell({ children }: { children: ReactNode }) { const [open, setOpen] = useState(true) const [mounted, setMounted] = useState(false) useEffect(() => { // До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet). const mq = window.matchMedia('(min-width: 1100px)') const sync = () => setOpen(mq.matches) sync() mq.addEventListener('change', sync) setMounted(true) return () => mq.removeEventListener('change', sync) }, []) const config = useChromeData() if (!mounted) { return (
) } return ( К содержимому
{children}
) }