212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
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<string, { badge: string; text: string }> = {
|
||
'/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<ChromeConfig>(() => {
|
||
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<PaletteObject>((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<PaletteObject>((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })),
|
||
...(isAdmin
|
||
? clients.map<PaletteObject>((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: <AppSwitcher />,
|
||
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 (
|
||
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
|
||
<Skeleton className="h-12 w-full" />
|
||
<Skeleton className="h-64 w-full" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<PortalUiProvider>
|
||
<ChromeConfigProvider value={config}>
|
||
<TooltipProvider delay={0}>
|
||
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||
К содержимому
|
||
</a>
|
||
<PortalSidebar />
|
||
<SidebarInset id="main-content" className="min-w-0">
|
||
<PortalHeader />
|
||
<div className="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||
</SidebarInset>
|
||
<CommandPalette />
|
||
</SidebarProvider>
|
||
</TooltipProvider>
|
||
</ChromeConfigProvider>
|
||
</PortalUiProvider>
|
||
)
|
||
}
|