Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b05fcf03a | ||
|
|
69b3553f6d | ||
|
|
148a778ba1 | ||
|
|
6898d2255d |
No files matched your search
@@ -0,0 +1,211 @@
|
||||
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="mx-auto flex w-full min-w-0 max-w-[1480px] 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>
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
import type { Tone } from './mock'
|
||||
import type { Tone } from './tone'
|
||||
import { TONE_BADGE } from './tone'
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
|
||||
@@ -104,7 +104,7 @@ export function SignalAlert({
|
||||
}
|
||||
|
||||
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
|
||||
export function PreviewSheet({
|
||||
export function PortalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import type { Tone } from './mock'
|
||||
import type { Tone } from './tone'
|
||||
import { StatusBadge } from './bits'
|
||||
|
||||
export type AppCardItem = {
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
|
||||
|
||||
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
|
||||
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
|
||||
}
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* без сохранения настроек */
|
||||
}
|
||||
}
|
||||
|
||||
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
|
||||
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
|
||||
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
|
||||
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
|
||||
|
||||
const setDensity = useCallback(
|
||||
(v: Density) => {
|
||||
setDensityState(v)
|
||||
writeStored(`${storageKey}:density`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const setContrast = useCallback(
|
||||
(v: Contrast) => {
|
||||
setContrastState(v)
|
||||
writeStored(`${storageKey}:contrast`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const togglePin = useCallback(
|
||||
(id: string) => {
|
||||
setPins((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
writeStored(`${storageKey}:pins`, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset.density = density
|
||||
root.dataset.contrast = contrast
|
||||
return () => {
|
||||
delete root.dataset.density
|
||||
delete root.dataset.contrast
|
||||
}
|
||||
}, [density, contrast])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setPaletteOpen((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<UiStore>(
|
||||
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
|
||||
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
|
||||
)
|
||||
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
group: string
|
||||
/** Точка проблемы в углу иконки. */
|
||||
problem?: { tone: 'er' | 'wn'; label: string }
|
||||
}
|
||||
|
||||
export type MonitorItem = {
|
||||
id: string
|
||||
tone: 'er' | 'wn' | 'ok'
|
||||
title: string
|
||||
text: string
|
||||
age: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
|
||||
|
||||
export type PaletteAction = {
|
||||
id: string
|
||||
label: string
|
||||
/** Горячая буква справа. */
|
||||
hint: string
|
||||
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
to: string
|
||||
}
|
||||
|
||||
export type PaletteObject = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
to: string
|
||||
}
|
||||
|
||||
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
|
||||
export type ChromeConfig = {
|
||||
navItems: NavItem[]
|
||||
navGroups: string[]
|
||||
/** Верх бокового меню: бренд и переключатель приложений. */
|
||||
brand: ReactNode
|
||||
user: { name: string; email: string; initials: string }
|
||||
securityTo: string
|
||||
onLogout: () => void
|
||||
fresh: (pathname: string) => { badge: string; text: string }
|
||||
crumbFor?: (pathname: string, label: string | undefined) => string
|
||||
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
|
||||
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
|
||||
}
|
||||
|
||||
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
|
||||
|
||||
export function useChromeConfig(): ChromeConfig {
|
||||
const ctx = useContext(ChromeConfigContext)
|
||||
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export type Density = 'comfortable' | 'compact'
|
||||
export type Contrast = 'standard' | 'high'
|
||||
|
||||
export type UiStore = {
|
||||
paletteOpen: boolean
|
||||
setPaletteOpen: (open: boolean) => void
|
||||
pins: string[]
|
||||
togglePin: (id: string) => void
|
||||
density: Density
|
||||
setDensity: (v: Density) => void
|
||||
contrast: Contrast
|
||||
setContrast: (v: Contrast) => void
|
||||
}
|
||||
|
||||
export const UiStoreContext = createContext<UiStore | null>(null)
|
||||
|
||||
export function useUiStore(): UiStore {
|
||||
const ctx = useContext(UiStoreContext)
|
||||
if (!ctx) throw new Error('PortalUiProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react'
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
InfoIcon,
|
||||
LogOutIcon,
|
||||
SearchIcon,
|
||||
StarIcon,
|
||||
MoonIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useTheme,
|
||||
} from 'next-themes'
|
||||
import {
|
||||
Badge,
|
||||
} from '@/components/reui/badge'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@authportal/ui/components/breadcrumb'
|
||||
import {
|
||||
Button,
|
||||
} from '@authportal/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@authportal/ui/components/dialog'
|
||||
import {
|
||||
Kbd,
|
||||
} from '@authportal/ui/components/kbd'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@authportal/ui/components/popover'
|
||||
import {
|
||||
Separator,
|
||||
} from '@authportal/ui/components/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import {
|
||||
cn,
|
||||
} from '@authportal/ui/lib/utils'
|
||||
import {
|
||||
Segmented,
|
||||
} from './bits'
|
||||
import {
|
||||
useChromeConfig,
|
||||
useUiStore,
|
||||
type NavItem,
|
||||
} from './chrome-store'
|
||||
|
||||
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||
|
||||
function PortalNavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||
const { user, securityTo, onLogout } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||
className="bg-muted/40 aria-expanded:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid leading-tight">
|
||||
<span className="text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: securityTo })}
|
||||
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||
>
|
||||
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
Безопасность
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={LABEL_CLASS}>Тема</span>
|
||||
<Segmented
|
||||
label="Тема"
|
||||
options={['Тёмная', 'Светлая']}
|
||||
value={current === 'light' ? 1 : 0}
|
||||
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Плотность</span>
|
||||
<Segmented
|
||||
label="Плотность"
|
||||
options={['Обычная', 'Компактная']}
|
||||
value={density === 'compact' ? 1 : 0}
|
||||
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Контраст</span>
|
||||
<Segmented
|
||||
label="Контраст"
|
||||
options={['Стандартный', 'Высокий']}
|
||||
value={contrast === 'high' ? 1 : 0}
|
||||
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden />
|
||||
Выйти
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||
|
||||
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||
const Icon = item.icon
|
||||
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn(
|
||||
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||
DOT_CLASS[item.problem.tone],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover={!pinned}
|
||||
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||
aria-pressed={pinned}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { pins, togglePin } = useUiStore()
|
||||
const { navItems, navGroups, brand } = useChromeConfig()
|
||||
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="pb-1.5">
|
||||
{brand}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{pinned.length > 0 ? (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pinned.map((item) => (
|
||||
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{navGroups.map((group, gi) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupLabel
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!closed[group]}
|
||||
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||
/>
|
||||
}
|
||||
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{group}
|
||||
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||
<SidebarMenu className="gap-px">
|
||||
{navItems.filter((i) => i.group === group).map((item) => (
|
||||
<NavEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
pinned={pins.includes(item.id)}
|
||||
onTogglePin={() => togglePin(item.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</div>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<PortalNavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||
function MonitorPopover() {
|
||||
const { monitor } = useChromeConfig()
|
||||
const MONITOR_ITEMS = monitor.items
|
||||
const MONITOR_METRICS = monitor.metrics
|
||||
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||
const errors = bad.filter((m) => m.tone === 'er').length
|
||||
const warnings = bad.length - errors
|
||||
const ok = MONITOR_ITEMS.length - bad.length
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{bad.length}
|
||||
</span>
|
||||
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Состояние системы</b>
|
||||
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
||||
</div>
|
||||
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||
].map((x) => (
|
||||
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MONITOR_METRICS.map((m) => (
|
||||
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
||||
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||
{monitor.footerLabel}
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function FreshnessPopover() {
|
||||
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||
const { fresh: freshFor } = useChromeConfig()
|
||||
const fresh = freshFor(pathname)
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
||||
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = useUiStore()
|
||||
const { navItems, crumbFor } = useChromeConfig()
|
||||
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<FreshnessPopover />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Командная палитра (⌘K)"
|
||||
className="md:hidden"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon aria-hidden />
|
||||
</Button>
|
||||
<MonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type PaletteEntry = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||
|
||||
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||
export function CommandPalette() {
|
||||
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||
const { navItems, palette } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||
const go = (to: string) => () => void navigate({ to })
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
const list: PaletteEntry[] = [
|
||||
...[
|
||||
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||
]
|
||||
.filter((e) => match(e.label))
|
||||
.map((e) => ({ ...e, group: 'Действия' })),
|
||||
...navItems.filter((i) => match(i.label, i.group))
|
||||
.slice(0, 7)
|
||||
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
||||
...Object.values(
|
||||
palette.objects
|
||||
.filter((o) => match(o.label, o.hint))
|
||||
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||
]
|
||||
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||
return [
|
||||
{
|
||||
id: 'ai',
|
||||
group: 'Ассистент · запрос на естественном языке',
|
||||
label: `Понял так: «${query.trim()}»`,
|
||||
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||
run: () => void navigate({ to: palette.assistantTo }),
|
||||
} as PaletteEntry,
|
||||
...list,
|
||||
]
|
||||
}
|
||||
return list
|
||||
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||
shown.forEach((e, index) => {
|
||||
const list = map.get(e.group) ?? []
|
||||
list.push({ ...e, index })
|
||||
map.set(e.group, list)
|
||||
})
|
||||
return [...map.entries()]
|
||||
}, [shown])
|
||||
|
||||
function close() {
|
||||
setPaletteOpen(false)
|
||||
setQuery('')
|
||||
setActive(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
||||
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
||||
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
||||
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
||||
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
aria-label="Поиск и команды"
|
||||
placeholder="Раздел, пользователь, приложение или действие…"
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.max(a - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
shown[active]?.run()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||
) : (
|
||||
groups.map(([group, list]) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
||||
{list.map((e) => {
|
||||
const Icon = e.icon
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={e.index === active}
|
||||
onMouseEnter={() => setActive(e.index)}
|
||||
onClick={() => {
|
||||
e.run()
|
||||
close()
|
||||
}}
|
||||
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
||||
>
|
||||
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
||||
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
||||
<span className="min-w-0 truncate">{e.label}</span>
|
||||
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
||||
<span>↑↓ выбор</span>
|
||||
<span>↵ открыть</span>
|
||||
<span>Esc закрыть</span>
|
||||
<span className="ml-auto">{palette.example}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits'
|
||||
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
|
||||
|
||||
/**
|
||||
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
|
||||
@@ -38,7 +38,7 @@ export function ExplainBanner({
|
||||
Объяснить
|
||||
</Button>
|
||||
</div>
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Ассистент · разбор"
|
||||
@@ -77,7 +77,7 @@ export function ExplainBanner({
|
||||
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
|
||||
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
|
||||
</SignalAlert>
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function parse(iso: string | null | undefined): Date | null {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** 12.09.2026 */
|
||||
export function fmtDate(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? DATE.format(d) : empty
|
||||
}
|
||||
|
||||
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
|
||||
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const startOfToday = new Date(now)
|
||||
startOfToday.setHours(0, 0, 0, 0)
|
||||
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
|
||||
if (diff === 0) return `сегодня ${TIME.format(d)}`
|
||||
if (diff === dayMs) return `вчера ${TIME.format(d)}`
|
||||
return `${SHORT.format(d)} ${TIME.format(d)}`
|
||||
}
|
||||
|
||||
/** Время дня 14:02. */
|
||||
export function fmtTime(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? TIME.format(d) : empty
|
||||
}
|
||||
|
||||
/** «5 дн назад», «3 ч назад», «только что». */
|
||||
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
|
||||
if (sec < 60) return 'только что'
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
|
||||
return `${Math.floor(sec / 86400)} дн назад`
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+3
-3
@@ -32,8 +32,8 @@ import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { StatusBadge } from './bits'
|
||||
import type { Tone } from './mock'
|
||||
import type { RowMark } from './preview-store'
|
||||
import type { Tone } from './tone'
|
||||
import type { RowMark } from './tone'
|
||||
import { TONE_DOT } from './tone'
|
||||
|
||||
export type GridColumn<T> = {
|
||||
@@ -83,7 +83,7 @@ const ACTIONS_COLUMN_SIZE = 120
|
||||
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
|
||||
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
|
||||
*/
|
||||
export function PreviewSectionGrid<T extends object>({
|
||||
export function SectionGrid<T extends object>({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Tone } from './mock'
|
||||
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
|
||||
|
||||
/** Пометка строки с накопленной правкой. */
|
||||
export type RowMark = 'new' | 'dirty' | 'deleted'
|
||||
|
||||
|
||||
type BadgeVariant =
|
||||
| 'success-light'
|
||||
@@ -16,4 +16,3 @@ export { UserAuditSheet } from './user-audit-sheet'
|
||||
export { CreateUserSheet } from './create-user-sheet'
|
||||
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||
export { PasskeySettingsPanel } from './passkey-settings-panel'
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Passkey management — DNA settings-16 / settings-2 / settings-10.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import {
|
||||
startRegistration,
|
||||
browserSupportsWebAuthn,
|
||||
} from '@simplewebauthn/browser'
|
||||
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
|
||||
import type { PasskeyCredential } from '@authportal/shared'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@authportal/ui/components/item'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
deletePasskey,
|
||||
passkeysQueryKey,
|
||||
passkeysQueryOptions,
|
||||
webauthnRegister,
|
||||
webauthnRegisterOptions,
|
||||
} from '@/queries/webauthn'
|
||||
|
||||
function formatWhen(iso: string | null) {
|
||||
if (!iso) return 'ещё не использовался'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function PasskeySettingsPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions)
|
||||
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(
|
||||
null,
|
||||
)
|
||||
const supported = browserSupportsWebAuthn()
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { challenge_id, options } = await webauthnRegisterOptions()
|
||||
const attResp = await startRegistration({
|
||||
optionsJSON:
|
||||
options as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||
})
|
||||
return webauthnRegister(challenge_id, attResp)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
toast.success('Passkey добавлен')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось добавить passkey',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deletePasskey(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
toast.success('Passkey удалён')
|
||||
setPendingDelete(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось удалить passkey',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<FrameTitle>Passkeys</FrameTitle>
|
||||
<FrameDescription>
|
||||
Вход без пароля: Windows Hello, Face ID, ключ безопасности
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!supported || registerMutation.isPending}
|
||||
onClick={() => registerMutation.mutate()}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{registerMutation.isPending ? 'Ожидание…' : 'Добавить passkey'}
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2">
|
||||
{!supported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Этот браузер не поддерживает WebAuthn.
|
||||
</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ключи не зарегистрированы. Добавьте passkey, чтобы входить без
|
||||
пароля.
|
||||
</p>
|
||||
) : (
|
||||
passkeys.map((item) => (
|
||||
<Item key={item.id} variant="outline" className="items-start">
|
||||
<ItemMedia variant="icon">
|
||||
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle className="flex flex-wrap items-center gap-2">
|
||||
{item.name}
|
||||
{item.device_type === 'multiDevice' ? (
|
||||
<Badge variant="info-outline" size="sm">
|
||||
Синхронизируется
|
||||
</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
Добавлен {formatWhen(item.created_at)} · вход{' '}
|
||||
{formatWhen(item.last_used_at)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Удалить ${item.name}`}
|
||||
onClick={() => setPendingDelete(item)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
Удалить
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDelete(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить passkey?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete
|
||||
? `«${pendingDelete.name}» больше нельзя будет использовать для входа.`
|
||||
: null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (pendingDelete) deleteMutation.mutate(pendingDelete.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { CodeBlock, SignalAlert } from './bits'
|
||||
import { CodeBlock, SignalAlert } from '@/components/portal/bits'
|
||||
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
|
||||
import { APP_SHORT, USER_JOURNAL } from './mock'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StatusBadge } from './bits'
|
||||
import { StatusBadge } from '@/components/portal/bits'
|
||||
import type { Tone } from './mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
type MatrixRow = {
|
||||
id: string
|
||||
@@ -39,7 +39,7 @@ const COLUMNS: GridColumn<MatrixRow>[] = [
|
||||
/** Матрица доступа по доске: сколько пользователей имеет доступ к каждому приложению и с каким уровнем. */
|
||||
export function AccessMatrix() {
|
||||
return (
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Матрица доступа"
|
||||
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
|
||||
rows={ROWS}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AdminUser } from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { AccessEditor } from './access-editor'
|
||||
import { diffAccess, type AccessValue } from './access-model'
|
||||
import { PreviewSheet } from './bits'
|
||||
import { PortalSheet } from '@/components/portal/bits'
|
||||
|
||||
/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */
|
||||
export function AccessSheet({
|
||||
@@ -19,7 +19,7 @@ export function AccessSheet({
|
||||
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
|
||||
|
||||
return (
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
size="lg"
|
||||
@@ -47,6 +47,6 @@ export function AccessSheet({
|
||||
}
|
||||
>
|
||||
<AccessEditor user={user} value={value} onChange={setValue} />
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Textarea } from '@authportal/ui/components/textarea'
|
||||
import { PreviewSheet, SignalAlert } from './bits'
|
||||
import { PortalSheet, SignalAlert } from '@/components/portal/bits'
|
||||
|
||||
/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */
|
||||
export function CreateUserPreviewSheet({
|
||||
export function CreateUserPortalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
@@ -37,7 +37,7 @@ export function CreateUserPreviewSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
@@ -93,12 +93,12 @@ export function CreateUserPreviewSheet({
|
||||
<SignalAlert tone="wn" title="Журнал">
|
||||
Создание записывается в журнал «Изменения» с вашим именем и IP.
|
||||
</SignalAlert>
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
)
|
||||
}
|
||||
|
||||
/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */
|
||||
export function CreateOidcPreviewSheet({
|
||||
export function CreateOidcPortalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
@@ -128,7 +128,7 @@ export function CreateOidcPreviewSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
@@ -189,6 +189,6 @@ export function CreateOidcPreviewSheet({
|
||||
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
type AuditLogEntry,
|
||||
} from '@authportal/shared'
|
||||
|
||||
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
|
||||
import type { Tone } from '@/components/portal/tone'
|
||||
export type { Tone }
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
|
||||
import { AppCards } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
|
||||
import { AppCards } from '@/components/portal/canvas-blocks'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { MOCK_SWITCHER } from '../mock'
|
||||
import { usePreviewStore } from '../preview-store'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
type LinkRow = {
|
||||
id: AppId
|
||||
@@ -180,7 +180,7 @@ export function AppLinksPage() {
|
||||
<PageTab value="menu" label="Меню" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="list" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Ссылки сервисов"
|
||||
description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
|
||||
rows={rows}
|
||||
@@ -247,7 +247,7 @@ export function AppLinksPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={editing !== null}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
title="Приложение"
|
||||
@@ -323,7 +323,7 @@ export function AppLinksPage() {
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { Link } from '@tanstack/react-router'
|
||||
import { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { APPS, type AppId } from '@authportal/shared'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { PageHead, PageTab, PageTabsList } from '../bits'
|
||||
import { AppCards, type AppCardItem } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
|
||||
const ICONS: Record<AppId, typeof CloudIcon> = {
|
||||
cfdm: CloudIcon,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PreviewSheet, SettingRow, StatusBadge, TypedConfirm } from '../bits'
|
||||
import { ActivityFeed } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
|
||||
import { ActivityFeed } from '@/components/portal/canvas-blocks'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function AuditPage() {
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="table" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Журнал изменений"
|
||||
description="Поиск по событию, пользователю и IP · виды по важности"
|
||||
rows={AUDIT_ROWS}
|
||||
@@ -113,7 +113,7 @@ export function AuditPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={entry !== null}
|
||||
onOpenChange={(o) => !o && setEntry(null)}
|
||||
title="Событие журнала"
|
||||
@@ -137,7 +137,7 @@ export function AuditPage() {
|
||||
</CodeBlock>
|
||||
</>
|
||||
) : null}
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
|
||||
<TypedConfirm
|
||||
open={purgeOpen}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import { SignalAlert } from '../bits'
|
||||
import { SignalAlert } from '@/components/portal/bits'
|
||||
|
||||
const PERKS = [
|
||||
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
|
||||
|
||||
@@ -2,14 +2,14 @@ import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } fr
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { ExplainBanner } from '../explain-banner'
|
||||
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList } from '../bits'
|
||||
import { kpi } from '../kpi'
|
||||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||||
import { Segmented, CodeBlock, KvList, PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
const PERIODS = ['24 ч', '7 д', '30 д', '90 д']
|
||||
|
||||
@@ -76,7 +76,7 @@ export function LoginsPage() {
|
||||
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Журнал входов"
|
||||
description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
|
||||
rows={MOCK_LOGINS}
|
||||
@@ -94,7 +94,7 @@ export function LoginsPage() {
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Активные сессии"
|
||||
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
|
||||
rows={sessions}
|
||||
@@ -112,7 +112,7 @@ export function LoginsPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={event !== null}
|
||||
onOpenChange={(o) => !o && setEvent(null)}
|
||||
title="Событие входа"
|
||||
@@ -148,7 +148,7 @@ export function LoginsPage() {
|
||||
<CodeBlock label="Событие">{`action: ${event.tone === 'er' ? 'auth.login_failed' : 'auth.login'}\nsource_app: portal\nseverity: ${event.tone === 'er' ? 'warning' : 'info'}\nip: ${event.ip}`}</CodeBlock>
|
||||
</>
|
||||
) : null}
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useMemo, useState } from 'react'
|
||||
import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
|
||||
import { CreateOidcPreviewSheet } from '../create-sheets'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { CreateOidcPortalSheet } from '../create-sheets'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -18,9 +18,9 @@ import { Input } from '@authportal/ui/components/input'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { Textarea } from '@authportal/ui/components/textarea'
|
||||
import { ExplainBanner } from '../explain-banner'
|
||||
import { CopyList, KvFrame, PageHead, PreviewSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '../bits'
|
||||
import { kpi } from '../kpi'
|
||||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||||
import { CopyList, KvFrame, PageHead, PortalSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import {
|
||||
MOCK_CLIENT_SECRET,
|
||||
MOCK_OIDC_CLIENTS,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type OidcClientRow,
|
||||
type Tone
|
||||
} from '../mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
function tone(c: OidcClientRow): Tone {
|
||||
return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok'
|
||||
@@ -111,7 +111,7 @@ export function OidcPage() {
|
||||
<PageTab value="meta" label="Параметры issuer" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Зарегистрированные клиенты"
|
||||
description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении"
|
||||
rows={clients}
|
||||
@@ -148,9 +148,9 @@ export function OidcPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CreateOidcPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
|
||||
<CreateOidcPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
|
||||
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={editing !== null}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
title="OIDC-клиент"
|
||||
@@ -193,7 +193,7 @@ export function OidcPage() {
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
|
||||
<TypedConfirm
|
||||
open={rotating !== null}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { ExplainBanner } from '../explain-banner'
|
||||
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
|
||||
import { kpi } from '../kpi'
|
||||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||||
import { KvFrame, KvList, PageHead, PortalSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
export function SecurityPage() {
|
||||
const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS)
|
||||
@@ -72,7 +72,7 @@ export function SecurityPage() {
|
||||
<PageTab value="acc" label="Учётная запись" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Мои passkeys"
|
||||
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
|
||||
rows={view}
|
||||
@@ -137,7 +137,7 @@ export function SecurityPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Добавить passkey"
|
||||
@@ -169,9 +169,9 @@ export function SecurityPage() {
|
||||
<SignalAlert tone="in" title="Что произойдёт дальше">
|
||||
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
|
||||
</SignalAlert>
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
|
||||
<PreviewSheet
|
||||
<PortalSheet
|
||||
open={selected !== null}
|
||||
onOpenChange={(o) => !o && setSelected(null)}
|
||||
title="Passkey"
|
||||
@@ -213,7 +213,7 @@ export function SecurityPage() {
|
||||
['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`],
|
||||
]}
|
||||
/>
|
||||
</PreviewSheet>
|
||||
</PortalSheet>
|
||||
|
||||
<TypedConfirm
|
||||
open={deleting !== null}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||
import { CreateUserPreviewSheet } from '../create-sheets'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { CreateUserPortalSheet } from '../create-sheets'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { AccessSheet } from '../access-sheet'
|
||||
import { diffAccess, type AccessValue } from '../access-model'
|
||||
import { PageHead, TypedConfirm, PageTab, PageTabsList } from '../bits'
|
||||
import { kpi } from '../kpi'
|
||||
import { PageHead, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { ALL_APP_IDS } from '../mock'
|
||||
import { ExplainBanner } from '../explain-banner'
|
||||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||||
import { AccessMatrix } from '../access-matrix'
|
||||
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
|
||||
import { usePreviewStore } from '../preview-store'
|
||||
@@ -131,7 +131,7 @@ export function UsersPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CreateUserPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
|
||||
<CreateUserPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
|
||||
|
||||
{accessFor ? (
|
||||
<AccessSheet
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Segmented } from './bits'
|
||||
import { Segmented } from '@/components/portal/bits'
|
||||
import { FRESH_BY_PAGE, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
|
||||
import { usePreviewStore } from './preview-store'
|
||||
|
||||
|
||||
@@ -48,11 +48,11 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset.previewDensity = density
|
||||
root.dataset.previewContrast = contrast
|
||||
root.dataset.density = density
|
||||
root.dataset.contrast = contrast
|
||||
return () => {
|
||||
delete root.dataset.previewDensity
|
||||
delete root.dataset.previewContrast
|
||||
delete root.dataset.density
|
||||
delete root.dataset.contrast
|
||||
}
|
||||
}, [density, contrast])
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar
|
||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
|
||||
import { UnsavedBar } from './unsaved-bar'
|
||||
import './design-preview.css'
|
||||
|
||||
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
|
||||
export function PreviewShell({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { AdminUser } from '@authportal/shared'
|
||||
|
||||
export type ChangeKind = 'add' | 'chg' | 'del'
|
||||
|
||||
export type RowMark = 'new' | 'dirty' | 'deleted'
|
||||
import type { RowMark } from '@/components/portal/tone'
|
||||
export type { RowMark }
|
||||
|
||||
export type PendingChange = {
|
||||
id: string
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useMemo } from 'react'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { PendingChip, StatusBadge } from './bits'
|
||||
import { PendingChip, StatusBadge } from '@/components/portal/bits'
|
||||
import type { RowMark } from './preview-store'
|
||||
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
|
||||
function userStatus(u: AdminUser): { tone: Tone; label: string } {
|
||||
if (u.disabled) return { tone: 'mu', label: 'Отключён' }
|
||||
@@ -65,7 +65,7 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
|
||||
)
|
||||
|
||||
return (
|
||||
<PreviewSectionGrid
|
||||
<SectionGrid
|
||||
title="Пользователи портала"
|
||||
description="Поиск по email и имени · фильтры: роль, статус, приложения, passkey · колонки настраиваются, вид сохраняется"
|
||||
rows={users}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from 'next-themes'
|
||||
import { Toaster } from 'sonner'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import '@authportal/ui/globals.css'
|
||||
import './portal-theme.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
@import '@fontsource-variable/geist-mono';
|
||||
|
||||
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
|
||||
html[data-preview-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
|
||||
html[data-preview-density] {
|
||||
html:not(.dark) {
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.205 0.01 264);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -43,7 +43,7 @@ html[data-preview-density] {
|
||||
--info-foreground: oklch(0.42 0.2 293);
|
||||
}
|
||||
|
||||
html[data-preview-density].dark {
|
||||
html.dark {
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
@@ -75,14 +75,14 @@ html[data-preview-density].dark {
|
||||
--info-foreground: oklch(0.541 0.281 293.009);
|
||||
}
|
||||
|
||||
html[data-preview-density][data-preview-contrast='high'] {
|
||||
html[data-contrast='high'] {
|
||||
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
|
||||
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
|
||||
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
|
||||
--ring: oklch(0.35 0.2 264);
|
||||
}
|
||||
|
||||
html[data-preview-density].dark[data-preview-contrast='high'] {
|
||||
html.dark[data-contrast='high'] {
|
||||
--background: oklch(0 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
@@ -106,40 +106,40 @@ html[data-preview-density].dark[data-preview-contrast='high'] {
|
||||
}
|
||||
|
||||
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
|
||||
html[data-preview-density] {
|
||||
html {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
html[data-preview-density],
|
||||
html[data-preview-density] body {
|
||||
html,
|
||||
html body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html[data-preview-density] [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html[data-preview-density] #main-content :focus-visible {
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html #main-content :focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-preview-density] #main-content *,
|
||||
html[data-preview-density] [data-slot='sidebar-wrapper'] * {
|
||||
html #main-content *,
|
||||
html [data-slot='sidebar-wrapper'] * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
|
||||
html[data-preview-density] [data-slot='data-grid-table'] thead th,
|
||||
html[data-preview-density] [data-slot='data-grid-table'] thead th :is(span, button, div) {
|
||||
html [data-slot='data-grid-table'] thead th,
|
||||
html [data-slot='data-grid-table'] thead th :is(span, button, div) {
|
||||
font-size: 0.75rem;
|
||||
background: color-mix(in oklch, var(--muted) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Затемнение под панелями без размытия — как на досках. */
|
||||
html[data-preview-density] :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
|
||||
html :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
|
||||
background: oklch(0 0 0 / 55%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
@@ -1,25 +1,280 @@
|
||||
/**
|
||||
* Account security — passkeys.
|
||||
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ClockIcon, FingerprintIcon, LayoutGridIcon, ShieldIcon, TrashIcon } from 'lucide-react'
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
|
||||
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
|
||||
import { toast } from 'sonner'
|
||||
import type { PasskeyCredential } from '@authportal/shared'
|
||||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||||
import { KvFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SignalAlert, StatusBadge, TypedConfirm } from '@/components/portal/bits'
|
||||
import { fmtDate, fmtWhen } from '@/components/portal/format'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { meQueryOptions } from '@/queries/auth'
|
||||
import {
|
||||
deletePasskey,
|
||||
passkeysQueryKey,
|
||||
passkeysQueryOptions,
|
||||
renamePasskey,
|
||||
webauthnRegister,
|
||||
webauthnRegisterOptions,
|
||||
} from '@/queries/webauthn'
|
||||
|
||||
export const Route = createFileRoute('/_auth/account')({
|
||||
component: AccountPage,
|
||||
})
|
||||
|
||||
const STALE_DAYS = 60
|
||||
|
||||
type Row = PasskeyCredential & { tone: 'ok' | 'wn'; status: string }
|
||||
|
||||
function toRow(k: PasskeyCredential, now: number): Row {
|
||||
const last = new Date(k.last_used_at ?? k.created_at).getTime()
|
||||
const stale = now - last > STALE_DAYS * 24 * 60 * 60 * 1000
|
||||
return { ...k, tone: stale ? 'wn' : 'ok', status: stale ? 'Давно не использовался' : 'Активен' }
|
||||
}
|
||||
|
||||
function kindOf(k: PasskeyCredential) {
|
||||
return k.device_type === 'multiDevice' ? 'Синхронизируемый' : k.device_type === 'singleDevice' ? 'Ключ устройства' : 'Passkey'
|
||||
}
|
||||
|
||||
function AccountPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: me } = useQuery(meQueryOptions)
|
||||
const { data: passkeys = [], isLoading, isError, refetch, dataUpdatedAt } = useQuery(passkeysQueryOptions)
|
||||
const supported = browserSupportsWebAuthn()
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [selected, setSelected] = useState<Row | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [deleting, setDeleting] = useState<Row | null>(null)
|
||||
|
||||
const rows = useMemo(() => passkeys.map((k) => toRow(k, dataUpdatedAt || Date.parse(k.created_at))), [passkeys, dataUpdatedAt])
|
||||
const lastUsed = rows.map((r) => r.last_used_at).filter((x): x is string => Boolean(x)).sort().at(-1) ?? null
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const { challenge_id, options } = await webauthnRegisterOptions()
|
||||
const att = await startRegistration({ optionsJSON: options as unknown as PublicKeyCredentialCreationOptionsJSON })
|
||||
return webauthnRegister(challenge_id, att, name.trim() || undefined)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
setAddOpen(false)
|
||||
setNewName('')
|
||||
toast.success('Passkey добавлен')
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось добавить passkey'),
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => renamePasskey(id, name),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
setSelected(null)
|
||||
toast.success('Название сохранено')
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось переименовать passkey'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deletePasskey(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
setDeleting(null)
|
||||
toast.success('Passkey удалён')
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось удалить passkey'),
|
||||
})
|
||||
|
||||
const columns = useMemo<GridColumn<Row>[]>(
|
||||
() => [
|
||||
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={kindOf(r)} /> },
|
||||
{ id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
|
||||
{ id: 'added', title: 'Добавлен', size: 120, sortValue: (r) => r.created_at, cell: (r) => <span className="font-mono text-[12.5px]">{fmtDate(r.created_at)}</span> },
|
||||
{ id: 'last', title: 'Последний вход', size: 180, sortValue: (r) => r.last_used_at ?? '', cell: (r) => <span className="font-mono text-[12.5px]">{r.last_used_at ? fmtWhen(r.last_used_at) : 'ещё не использовался'}</span> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const open = (r: Row) => {
|
||||
setSelected(r)
|
||||
setRenameValue(r.name)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Passkey как альтернатива паролю. Пароль остаётся запасным входом.
|
||||
</p>
|
||||
</div>
|
||||
<PasskeySettingsPanel />
|
||||
</PageShell>
|
||||
<>
|
||||
<PageHead
|
||||
title="Безопасность"
|
||||
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
|
||||
actions={
|
||||
<Button disabled={!supported} onClick={() => setAddOpen(true)}>
|
||||
Добавить passkey
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!supported ? (
|
||||
<SignalAlert tone="wn" title="Браузер не поддерживает passkeys.">
|
||||
Откройте портал по HTTPS в современном браузере. Вход по паролю остаётся доступным.
|
||||
</SignalAlert>
|
||||
) : null}
|
||||
{supported && !isLoading && rows.length <= 1 ? (
|
||||
<ExplainBanner
|
||||
tone="wn"
|
||||
title={rows.length === 0 ? 'Passkey не добавлен.' : 'Один passkey — единственная копия.'}
|
||||
text={
|
||||
rows.length === 0
|
||||
? 'Сейчас вход возможен только по паролю. Добавьте passkey, чтобы входить отпечатком, Face ID или ключом безопасности.'
|
||||
: 'Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.'
|
||||
}
|
||||
>
|
||||
Совет: держите два ключа на разных устройствах, например платформенный и ключ безопасности.
|
||||
</ExplainBanner>
|
||||
) : null}
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('keys', 'Passkeys', String(rows.length), { icon: FingerprintIcon, tone: rows.length ? 'ok' : 'wn', badge: rows.length ? 'активны' : 'не добавлены' }),
|
||||
kpi('last', 'Последний вход', lastUsed ? fmtWhen(lastUsed) : '—', { icon: ClockIcon, badge: 'по passkey' }),
|
||||
kpi('apps', 'Приложений', String(me?.apps.length ?? 0), { icon: LayoutGridIcon, badge: 'доступно вам' }),
|
||||
kpi('role', 'Роль', me?.is_admin ? 'Админ' : 'Пользователь', { icon: ShieldIcon, badge: me?.is_admin ? 'is_admin' : 'по правам' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="keys" className="min-w-0">
|
||||
<PageTabsList>
|
||||
<PageTab value="keys" label="Passkeys" count={rows.length} />
|
||||
<PageTab value="acc" label="Учётная запись" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
) : isError ? (
|
||||
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||
<span>Не удалось загрузить passkeys. Повторите запрос.</span>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<SectionGrid
|
||||
title="Мои passkeys"
|
||||
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
searchText={(r) => `${r.name} ${kindOf(r)}`}
|
||||
selectable={false}
|
||||
views={[
|
||||
{ label: 'Все', match: () => true },
|
||||
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' },
|
||||
]}
|
||||
rowMenu={(r) => ({ onOpen: () => open(r), onDelete: () => setDeleting(r) })}
|
||||
onRowClick={open}
|
||||
emptyMessage="Ключи не зарегистрированы. Добавьте passkey, чтобы входить без пароля."
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="acc">
|
||||
<KvFrame
|
||||
title={me?.email ?? '—'}
|
||||
description="Данные учётной записи"
|
||||
items={[
|
||||
{ label: 'Имя', value: me?.name ?? '—' },
|
||||
{ label: 'Роль', value: me?.is_admin ? 'Администратор' : 'Пользователь' },
|
||||
{ label: 'Приложений', value: String(me?.apps.length ?? 0) },
|
||||
{ label: 'Двухфакторная защита', value: rows.length ? `Passkey ×${rows.length}` : 'Не настроена' },
|
||||
]}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PortalSheet
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Добавить passkey"
|
||||
description="Браузер предложит создать ключ для этого портала"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button disabled={registerMutation.isPending} onClick={() => registerMutation.mutate(newName)}>
|
||||
{registerMutation.isPending ? 'Ожидание…' : 'Создать ключ'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pk-name">Название ключа</FieldLabel>
|
||||
<Input id="pk-name" value={newName} placeholder="Рабочий ноутбук" onChange={(e) => setNewName(e.target.value)} />
|
||||
<FieldDescription>Чтобы отличать устройства в списке</FieldDescription>
|
||||
</Field>
|
||||
<SignalAlert tone="in" title="Что произойдёт дальше">
|
||||
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
|
||||
</SignalAlert>
|
||||
</PortalSheet>
|
||||
|
||||
<PortalSheet
|
||||
open={selected !== null}
|
||||
onOpenChange={(o) => !o && setSelected(null)}
|
||||
title="Passkey"
|
||||
description="Название и сведения о ключе"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setDeleting(selected)
|
||||
setSelected(null)
|
||||
}}
|
||||
>
|
||||
<TrashIcon aria-hidden />
|
||||
Удалить
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
disabled={renameMutation.isPending || !renameValue.trim() || renameValue.trim() === selected?.name}
|
||||
onClick={() => selected && renameMutation.mutate({ id: selected.id, name: renameValue.trim() })}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pk-rename">Название</FieldLabel>
|
||||
<Input id="pk-rename" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
|
||||
</Field>
|
||||
<KvList
|
||||
items={[
|
||||
['Тип', selected ? kindOf(selected) : ''],
|
||||
['Добавлен', fmtDate(selected?.created_at)],
|
||||
['Последний вход', selected?.last_used_at ? fmtWhen(selected.last_used_at) : 'ещё не использовался'],
|
||||
]}
|
||||
/>
|
||||
</PortalSheet>
|
||||
|
||||
<TypedConfirm
|
||||
open={deleting !== null}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Удалить passkey?"
|
||||
description={`Ключ «${deleting?.name ?? ''}» будет удалён без возможности восстановления. Войти по нему больше не получится.`}
|
||||
expected="удалить"
|
||||
confirmLabel="Удалить ключ"
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { defaultAppSwitcherConfig } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHead } from '@/components/portal/bits'
|
||||
import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
|
||||
import {
|
||||
Frame,
|
||||
@@ -47,13 +47,8 @@ function AdminAppsPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<PageHead title="Ссылки приложений" sub="URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)" />
|
||||
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
@@ -84,6 +79,6 @@ function AdminAppsPage() {
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</PageShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +1,274 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
AuditPurgeResponse,
|
||||
AuditSettings,
|
||||
} from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
auditQueryKey,
|
||||
auditQueryOptions,
|
||||
auditSettingsQueryKey,
|
||||
auditSettingsQueryOptions,
|
||||
} from '@/queries/audit'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import type { AuditLogEntry, AuditPurgeResponse, AuditSettings, AuditSeverity } from '@authportal/shared'
|
||||
import { actionMeta, exportAuditCsv, severityLabel, sourceAppLabel } from '@/components/reui-kit/audit-log-helpers'
|
||||
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
|
||||
import { ActivityFeed } from '@/components/portal/canvas-blocks'
|
||||
import { fmtTime, fmtWhen } from '@/components/portal/format'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
import type { Tone } from '@/components/portal/tone'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { auditQueryKey, auditQueryOptions, auditSettingsQueryKey, auditSettingsQueryOptions } from '@/queries/audit'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/audit')({
|
||||
component: AdminAuditPage,
|
||||
})
|
||||
|
||||
const SEVERITY_TONE: Record<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' }
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
action: string
|
||||
summary: string
|
||||
tone: Tone
|
||||
label: string
|
||||
source: string
|
||||
who: string
|
||||
ip: string
|
||||
when: string
|
||||
entry: AuditLogEntry
|
||||
}
|
||||
|
||||
function AdminAuditPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
data: entries = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
|
||||
const [tab, setTab] = useState('feed')
|
||||
const [entry, setEntry] = useState<Row | null>(null)
|
||||
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||
const [daysDraft, setDaysDraft] = useState<string | null>(null)
|
||||
|
||||
const entriesQ = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
|
||||
const { data: settings } = useQuery(auditSettingsQueryOptions)
|
||||
const retention = settings?.retention_days ?? 90
|
||||
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (retention_days: number) =>
|
||||
api.put<AuditSettings>('/api/v1/admin/audit/settings', {
|
||||
retention_days,
|
||||
}),
|
||||
mutationFn: (retention_days: number) => api.put<AuditSettings>('/api/v1/admin/audit/settings', { retention_days }),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey })
|
||||
await queryClient.invalidateQueries({ queryKey: auditQueryKey })
|
||||
setDaysDraft(null)
|
||||
toast.success(`Срок хранения: ${data.retention_days} дн.`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось сохранить',
|
||||
)
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
const purgeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
|
||||
mutationFn: () => api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: auditQueryKey })
|
||||
setPurgeOpen(false)
|
||||
toast.success(`Удалено записей: ${data.deleted}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки')
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки'),
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-6">
|
||||
<AuditRetentionPanel
|
||||
retentionDays={settings?.retention_days ?? 90}
|
||||
saving={saveMutation.isPending}
|
||||
purging={purgeMutation.isPending}
|
||||
onSave={(days) => saveMutation.mutate(days)}
|
||||
onPurge={() => purgeMutation.mutate()}
|
||||
/>
|
||||
const rows = useMemo<Row[]>(
|
||||
() =>
|
||||
entries.map((e) => ({
|
||||
id: e.id,
|
||||
action: e.action,
|
||||
summary: e.summary,
|
||||
tone: SEVERITY_TONE[e.severity],
|
||||
label: severityLabel[e.severity],
|
||||
source: sourceAppLabel(e.source_app),
|
||||
who: e.actor_email?.split('@')[0] ?? 'system',
|
||||
ip: e.ip ?? '—',
|
||||
when: fmtWhen(e.created_at),
|
||||
entry: e,
|
||||
})),
|
||||
[entries],
|
||||
)
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Повторить
|
||||
const feed = useMemo(
|
||||
() =>
|
||||
rows.slice(0, 5).map((r) => ({
|
||||
t: fmtTime(r.entry.created_at),
|
||||
title: actionMeta(r.action).label,
|
||||
text: `${r.summary} · ${r.who} · ${r.source}`,
|
||||
label: r.label,
|
||||
tone: r.tone,
|
||||
})),
|
||||
[rows],
|
||||
)
|
||||
|
||||
const columns = useMemo<GridColumn<Row>[]>(
|
||||
() => [
|
||||
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={r.tone} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
|
||||
{ id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.label}</StatusBadge> },
|
||||
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.source}</span> },
|
||||
{ id: 'who', title: 'Кто', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.who}</span> },
|
||||
{ id: 'ip', title: 'IP', size: 140, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.ip}</span> },
|
||||
{ id: 'when', title: 'Когда', size: 150, sortValue: (r) => r.entry.created_at, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.when}</span> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const stats = {
|
||||
all: entries.length,
|
||||
access: entries.filter((e) => e.action === 'user.access_update').length,
|
||||
critical: entries.filter((e) => e.severity === 'critical').length,
|
||||
sources: new Set(entries.map((e) => e.source_app)).size,
|
||||
}
|
||||
|
||||
const daysValue = daysDraft ?? String(retention)
|
||||
const parsedDays = Number.parseInt(daysValue, 10)
|
||||
const daysValid = Number.isFinite(parsedDays) && parsedDays >= 7 && parsedDays <= 3650
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Изменения"
|
||||
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" disabled={rows.length === 0} onClick={() => exportAuditCsv(entries)}>
|
||||
Экспорт CSV
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AuditLogTimeline
|
||||
entries={entries as AuditLogEntry[]}
|
||||
totalCount={entries.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
|
||||
Очистить старые записи…
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{entriesQ.isError ? (
|
||||
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||
<span>{entriesQ.error instanceof ApiError ? entriesQ.error.message : 'Не удалось загрузить журнал. Повторите запрос.'}</span>
|
||||
<Button variant="outline" onClick={() => void entriesQ.refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('all', 'Событий', String(stats.all), { icon: HistoryIcon, badge: 'в выборке' }),
|
||||
kpi('acc', 'Изменений прав', String(stats.access), { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
|
||||
kpi('crit', 'Критичных', String(stats.critical), { icon: OctagonAlertIcon, tone: stats.critical ? 'er' : 'neutral', badge: 'critical' }),
|
||||
kpi('src', 'Источников', String(stats.sources), { icon: NetworkIcon, badge: 'портал и приложения' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
|
||||
<PageTabsList>
|
||||
<PageTab value="feed" label="Лента" />
|
||||
<PageTab value="table" label="Таблица" count={rows.length} />
|
||||
<PageTab value="retention" label="Ротация" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="feed" className="min-w-0">
|
||||
{entriesQ.isLoading ? (
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
) : (
|
||||
<ActivityFeed
|
||||
title="Лента изменений"
|
||||
description="Последние события · цвет точки — важность"
|
||||
items={feed}
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setTab('table')}>
|
||||
Весь журнал
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="table" className="min-w-0">
|
||||
{entriesQ.isLoading ? (
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
) : (
|
||||
<SectionGrid
|
||||
title="Журнал изменений"
|
||||
description="Поиск по событию, пользователю и IP · виды по важности"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
|
||||
selectable={false}
|
||||
views={[
|
||||
{ label: 'Все', match: () => true },
|
||||
{ label: 'Warning и выше', match: (r) => r.tone === 'wn' || r.tone === 'er' },
|
||||
{ label: 'Critical', match: (r) => r.tone === 'er' },
|
||||
]}
|
||||
onRowClick={setEntry}
|
||||
emptyMessage="В журнале нет записей."
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="retention">
|
||||
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
|
||||
<SettingRow title="Срок хранения" description="Записи старше удаляются автоматически каждый час · от 7 до 3650 дней">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={7}
|
||||
max={3650}
|
||||
value={daysValue}
|
||||
onChange={(e) => setDaysDraft(e.target.value)}
|
||||
aria-label="Срок хранения, дней"
|
||||
className="w-28 tabular-nums"
|
||||
/>
|
||||
<Button disabled={!daysValid || saveMutation.isPending || parsedDays === retention} onClick={() => saveMutation.mutate(parsedDays)}>
|
||||
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow title="Записей в журнале" description="В выборке изменений">
|
||||
<StatusBadge tone="mu">{String(entries.length)}</StatusBadge>
|
||||
</SettingRow>
|
||||
</FormFrame>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<PortalSheet
|
||||
open={entry !== null}
|
||||
onOpenChange={(o) => !o && setEntry(null)}
|
||||
title="Событие журнала"
|
||||
description="Детали записи"
|
||||
footer={
|
||||
<Button variant="outline" onClick={() => setEntry(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{entry ? (
|
||||
<>
|
||||
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
|
||||
<div>
|
||||
<StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
|
||||
</div>
|
||||
<KvList
|
||||
items={[
|
||||
['Источник', entry.source],
|
||||
['Кто', `${entry.entry.actor_email ?? 'system'} · ${entry.ip}`],
|
||||
['Когда', fmtWhen(entry.entry.created_at)],
|
||||
]}
|
||||
/>
|
||||
<CodeBlock label="Подробности">
|
||||
{JSON.stringify(
|
||||
{ target_type: entry.entry.target_type, target_id: entry.entry.target_id, details: entry.entry.details },
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</CodeBlock>
|
||||
</>
|
||||
) : null}
|
||||
</PortalSheet>
|
||||
|
||||
<TypedConfirm
|
||||
open={purgeOpen}
|
||||
onOpenChange={setPurgeOpen}
|
||||
title="Очистить старые записи?"
|
||||
description={`Будут безвозвратно удалены записи старше ${retention} дней. Само действие попадёт в журнал как audit.purge.`}
|
||||
expected="очистить"
|
||||
confirmLabel="Очистить"
|
||||
onConfirm={() => purgeMutation.mutate()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
||||
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||
@@ -175,7 +174,7 @@ function AdminUsersPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<>
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
@@ -229,6 +228,6 @@ function AdminUsersPage() {
|
||||
if (!next) setAuditUser(null)
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,542 +1,315 @@
|
||||
/**
|
||||
* Admin logins journal — history DataGrid + active sessions.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/stats-12
|
||||
* Sessions DNA: https://reui.io/preview/base/solution-users-2
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AdminSession, AuditLogEntry } from '@authportal/shared'
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
LogInIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import {
|
||||
actionMeta,
|
||||
detailString,
|
||||
formatEventTime,
|
||||
matchesRange,
|
||||
sourceAppLabel,
|
||||
type AuditRange,
|
||||
} from '@/components/reui-kit/audit-log-helpers'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DATA_GRID_RU } from '@/lib/reui-ru'
|
||||
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { auditQueryOptions } from '@/queries/audit'
|
||||
import {
|
||||
sessionsListQueryKey,
|
||||
sessionsQueryOptions,
|
||||
} from '@/queries/sessions'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { actionMeta, detailString, exportAuditCsv, matchesRange, sourceAppLabel, type AuditRange } from '@/components/reui-kit/audit-log-helpers'
|
||||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||||
import { CodeBlock, KvList, PageHead, PageTab, PageTabsList, PortalSheet, Segmented, StatusBadge } from '@/components/portal/bits'
|
||||
import { fmtWhen } from '@/components/portal/format'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
import type { Tone } from '@/components/portal/tone'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { auditQueryOptions } from '@/queries/audit'
|
||||
import { sessionsListQueryKey, sessionsQueryOptions } from '@/queries/sessions'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/logins')({
|
||||
component: AdminLoginsPage,
|
||||
})
|
||||
|
||||
type ResultFilter = 'all' | 'success' | 'failed'
|
||||
const PERIODS: Array<{ label: string; value: AuditRange }> = [
|
||||
{ label: '24 ч', value: '24h' },
|
||||
{ label: '7 д', value: '7d' },
|
||||
{ label: '30 д', value: '30d' },
|
||||
{ label: '90 д', value: '90d' },
|
||||
]
|
||||
|
||||
function resultVariant(action: string) {
|
||||
if (action === 'auth.login_failed') return 'warning-outline' as const
|
||||
if (action === 'auth.sso_handoff') return 'info-outline' as const
|
||||
return 'success-outline' as const
|
||||
type LoginRow = {
|
||||
id: string
|
||||
login: string
|
||||
client: string
|
||||
tone: Tone
|
||||
status: string
|
||||
time: string
|
||||
ip: string
|
||||
app: string
|
||||
reason: string
|
||||
entry: AuditLogEntry
|
||||
}
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
user: string
|
||||
note: string
|
||||
ipClient: string
|
||||
created: string
|
||||
expires: string
|
||||
}
|
||||
|
||||
const FAILED = 'auth.login_failed'
|
||||
const isOk = (a: string) => a === 'auth.login' || a === 'auth.sso_handoff'
|
||||
|
||||
function toneOf(action: string): Tone {
|
||||
if (action === FAILED) return 'er'
|
||||
if (action === 'auth.sso_handoff') return 'in'
|
||||
if (action === 'auth.logout') return 'wn'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function shortUa(ua: string | null | undefined) {
|
||||
if (!ua) return '—'
|
||||
const m = /(Edg|Chrome|Firefox|Safari)\/(\d+)/.exec(ua)
|
||||
const os = /Windows|Mac OS X|Linux|iPhone|Android/.exec(ua)?.[0]?.replace('Mac OS X', 'macOS')
|
||||
return [m ? `${m[1] === 'Edg' ? 'Edge' : m[1]} ${m[2]}` : ua.slice(0, 24), os].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function AdminLoginsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState<'history' | 'sessions'>('history')
|
||||
const [resultFilter, setResultFilter] = useState<ResultFilter>('all')
|
||||
const [range, setRange] = useState<AuditRange>('7d')
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 20,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'created_at', desc: true },
|
||||
])
|
||||
const navigate = useNavigate()
|
||||
const [range, setRange] = useState(0)
|
||||
const [event, setEvent] = useState<LoginRow | null>(null)
|
||||
|
||||
const {
|
||||
data: entries = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
|
||||
const entriesQ = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
|
||||
const sessionsQ = useQuery(sessionsQueryOptions())
|
||||
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
|
||||
const sessions = useMemo(() => sessionsQ.data ?? [], [sessionsQ.data])
|
||||
const period = PERIODS[range]!.value
|
||||
|
||||
const {
|
||||
data: sessions = [],
|
||||
isLoading: sessionsLoading,
|
||||
error: sessionsError,
|
||||
refetch: refetchSessions,
|
||||
} = useQuery(sessionsQueryOptions())
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() })
|
||||
toast.success('Сессия отозвана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
|
||||
)
|
||||
toast.success('Сессия завершена')
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось завершить сессию'),
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return entries.filter((e) => {
|
||||
if (!matchesRange(e, range)) return false
|
||||
if (resultFilter === 'success') {
|
||||
return e.action === 'auth.login' || e.action === 'auth.sso_handoff'
|
||||
}
|
||||
if (resultFilter === 'failed') return e.action === 'auth.login_failed'
|
||||
return true
|
||||
})
|
||||
}, [entries, range, resultFilter])
|
||||
const rows = useMemo<LoginRow[]>(
|
||||
() =>
|
||||
entries
|
||||
.filter((e) => matchesRange(e, period))
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
login: e.actor_email ?? '—',
|
||||
client: shortUa(detailString(e.details, 'user_agent')),
|
||||
tone: toneOf(e.action),
|
||||
status: actionMeta(e.action).label,
|
||||
time: fmtWhen(e.created_at),
|
||||
ip: e.ip ?? '—',
|
||||
app: sourceAppLabel(detailString(e.details, 'target_app') ?? e.source_app),
|
||||
reason: detailString(e.details, 'reason') ?? '—',
|
||||
entry: e,
|
||||
})),
|
||||
[entries, period],
|
||||
)
|
||||
|
||||
const kpi = useMemo(() => {
|
||||
const sessionRows = useMemo<SessionRow[]>(
|
||||
() =>
|
||||
sessions.map((s: AdminSession) => ({
|
||||
id: s.id,
|
||||
user: s.email,
|
||||
note: s.name,
|
||||
ipClient: `${s.ip ?? '—'} · ${shortUa(s.user_agent)}`,
|
||||
created: fmtWhen(s.created_at),
|
||||
expires: fmtWhen(s.expires_at),
|
||||
})),
|
||||
[sessions],
|
||||
)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const day = entries.filter((e) => matchesRange(e, '24h'))
|
||||
const ok = day.filter(
|
||||
(e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff',
|
||||
).length
|
||||
const fail = day.filter((e) => e.action === 'auth.login_failed').length
|
||||
const ips = new Set(
|
||||
day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)),
|
||||
)
|
||||
return { ok, fail, ips: ips.size, sessions: sessions.length }
|
||||
}, [entries, sessions.length])
|
||||
return {
|
||||
ok: day.filter((e) => isOk(e.action)).length,
|
||||
fail: day.filter((e) => e.action === FAILED).length,
|
||||
ips: new Set(day.map((e) => e.ip).filter(Boolean)).size,
|
||||
users: new Set(sessions.map((s) => s.user_id)).size,
|
||||
}
|
||||
}, [entries, sessions])
|
||||
|
||||
const historyColumns = useMemo<ColumnDef<DataGridFeatures, AuditLogEntry>[]>(
|
||||
// Серия неудач: 5 и больше с одного IP за последние 10 минут (по времени самой свежей записи журнала).
|
||||
const burst = useMemo(() => {
|
||||
const latest = entries.reduce((m, e) => Math.max(m, new Date(e.created_at).getTime()), 0)
|
||||
const byIp = new Map<string, AuditLogEntry[]>()
|
||||
for (const e of entries) {
|
||||
if (e.action !== FAILED || !e.ip) continue
|
||||
if (latest - new Date(e.created_at).getTime() > 10 * 60 * 1000) continue
|
||||
byIp.set(e.ip, [...(byIp.get(e.ip) ?? []), e])
|
||||
}
|
||||
const top = [...byIp.entries()].sort((a, b) => b[1].length - a[1].length)[0]
|
||||
return top && top[1].length >= 5 ? { ip: top[0], n: top[1].length, login: top[1][0]!.actor_email ?? '—' } : null
|
||||
}, [entries])
|
||||
|
||||
const loginCols = useMemo<GridColumn<LoginRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Время" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{formatEventTime(row.original.created_at)}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
id: 'result',
|
||||
accessorFn: (r) => r.action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Результат" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={resultVariant(row.original.action)} size="sm">
|
||||
{actionMeta(row.original.action).label}
|
||||
</Badge>
|
||||
),
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
accessorKey: 'actor_email',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Логин" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate text-sm font-medium">
|
||||
{row.original.actor_email ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="IP" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.ip ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 130,
|
||||
},
|
||||
{
|
||||
id: 'ua',
|
||||
accessorFn: (r) => detailString(r.details, 'user_agent') ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="User-Agent" column={column} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ua = detailString(row.original.details, 'user_agent')
|
||||
return (
|
||||
<span className="text-muted-foreground line-clamp-1 max-w-[220px] text-xs" title={ua ?? undefined}>
|
||||
{ua ?? '—'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 220,
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
accessorFn: (r) =>
|
||||
detailString(r.details, 'target_app') ?? r.source_app,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Приложение" column={column} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const app =
|
||||
detailString(row.original.details, 'target_app') ??
|
||||
row.original.source_app
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
{sourceAppLabel(app)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'reason',
|
||||
accessorFn: (r) => detailString(r.details, 'reason') ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Причина" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{detailString(row.original.details, 'reason') ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 120,
|
||||
},
|
||||
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
|
||||
{ id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
|
||||
{ id: 'time', title: 'Время', size: 150, sortValue: (r) => r.entry.created_at, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.time}</span> },
|
||||
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[12.5px]">{r.ip}</span> },
|
||||
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.app}</span> },
|
||||
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const historyTable = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filtered,
|
||||
columns: historyColumns,
|
||||
state: { pagination, sorting },
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
})
|
||||
|
||||
const sessionColumns = useMemo<ColumnDef<DataGridFeatures, AdminSession>[]>(
|
||||
const sessionCols = useMemo<GridColumn<SessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Пользователь" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{row.original.email}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="IP" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.ip ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 130,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_agent',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="User-Agent" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="text-muted-foreground line-clamp-1 max-w-[240px] text-xs"
|
||||
title={row.original.user_agent ?? undefined}
|
||||
>
|
||||
{row.original.user_agent ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 240,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Создана" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{formatEventTime(row.original.created_at)}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Истекает" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{formatEventTime(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate(row.original.id)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
Отозвать
|
||||
</Button>
|
||||
),
|
||||
size: 120,
|
||||
},
|
||||
{ id: 'user', title: 'Пользователь', size: 260, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot="ok" title={r.user} sub={r.note} /> },
|
||||
{ id: 'status', title: 'Статус', size: 110, cell: () => <StatusBadge tone="ok">Активна</StatusBadge> },
|
||||
{ id: 'ip', title: 'IP · клиент', size: 280, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
|
||||
{ id: 'created', title: 'Создана', size: 150, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
|
||||
{ id: 'exp', title: 'Истекает', size: 150, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
|
||||
],
|
||||
[revokeMutation],
|
||||
[],
|
||||
)
|
||||
|
||||
const [sessionPagination, setSessionPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 20,
|
||||
})
|
||||
const sessionsTable = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: sessions,
|
||||
columns: sessionColumns,
|
||||
state: { pagination: sessionPagination },
|
||||
onPaginationChange: setSessionPagination,
|
||||
})
|
||||
const loading = entriesQ.isLoading || sessionsQ.isLoading
|
||||
const failedQuery = entriesQ.isError || sessionsQ.isError
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-6">
|
||||
<KpiStatGrid
|
||||
items={[
|
||||
{
|
||||
id: 'ok',
|
||||
label: 'Успешные (24ч)',
|
||||
value: kpi.ok,
|
||||
icon: <LogInIcon />,
|
||||
iconClassName: 'text-success',
|
||||
hint: 'Вход и SSO',
|
||||
},
|
||||
{
|
||||
id: 'fail',
|
||||
label: 'Неудачи (24ч)',
|
||||
value: kpi.fail,
|
||||
icon: <ShieldAlertIcon />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: kpi.fail > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
id: 'ips',
|
||||
label: 'Уникальные IP (24ч)',
|
||||
value: kpi.ips,
|
||||
icon: <KeyRoundIcon />,
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Активные сессии',
|
||||
value: kpi.sessions,
|
||||
icon: <KeyRoundIcon />,
|
||||
onSelect: () => setTab('sessions'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'history' || v === 'sessions') setTab(v)
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="h-10! w-full justify-start gap-6 border-b"
|
||||
<>
|
||||
<PageHead
|
||||
title="Входы"
|
||||
sub="Журнал входов и активные сессии · портал и SSO в приложения"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => {
|
||||
exportAuditCsv(rows.map((r) => r.entry))
|
||||
toast.success(`CSV сформирован: ${rows.length} записей`)
|
||||
}}
|
||||
>
|
||||
<TabsTrigger value="history" className="px-1 text-sm after:-bottom-px!">
|
||||
История
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="px-1 text-sm after:-bottom-px!">
|
||||
Активные сессии
|
||||
<span className="bg-muted text-muted-foreground ms-2 rounded-md px-1.5 text-xs tabular-nums">
|
||||
{sessions.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
Экспорт CSV
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{burst ? (
|
||||
<ExplainBanner
|
||||
tone="er"
|
||||
title={`Подозрительная серия неудач: ${burst.login}.`}
|
||||
text={`${burst.n} неудачных входов с ${burst.ip} за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».`}
|
||||
>
|
||||
{burst.n} неудачных входов с {burst.ip} за 10 минут. Проверьте журнал и при необходимости отключите учётную запись или заблокируйте адрес.
|
||||
</ExplainBanner>
|
||||
) : null}
|
||||
{failedQuery ? (
|
||||
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||
<span>Не удалось загрузить журнал. Повторите запрос.</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void entriesQ.refetch()
|
||||
void sessionsQ.refetch()
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Segmented label="Период" options={PERIODS.map((p) => p.label)} value={range} onChange={setRange} />
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('ok', 'Успешные (24ч)', String(stats.ok), { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
|
||||
kpi('fail', 'Неудачи (24ч)', String(stats.fail), { icon: ShieldAlertIcon, tone: stats.fail ? 'er' : 'neutral', badge: 'неверный пароль и др.' }),
|
||||
kpi('ip', 'Уникальные IP (24ч)', String(stats.ips), { icon: GlobeIcon, badge: 'по журналу' }),
|
||||
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: `${stats.users} пользователей` }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="hist" className="min-w-0">
|
||||
<PageTabsList>
|
||||
<PageTab value="hist" label="История" count={rows.length} />
|
||||
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
) : (
|
||||
<SectionGrid
|
||||
title="Журнал входов"
|
||||
description="Поиск по логину и IP · виды по результату · период выбирается выше"
|
||||
rows={rows}
|
||||
columns={loginCols}
|
||||
getRowId={(r) => r.id}
|
||||
searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
|
||||
searchPlaceholder="Логин или IP"
|
||||
selectable={false}
|
||||
views={[
|
||||
{ label: 'Все', match: () => true },
|
||||
{ label: 'Успешные', match: (r) => r.tone === 'ok' || r.tone === 'in' },
|
||||
{ label: 'Неудачные', match: (r) => r.tone === 'er' || r.tone === 'wn' },
|
||||
]}
|
||||
onRowClick={setEvent}
|
||||
emptyMessage="За выбранный период входов не было."
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
) : (
|
||||
<SectionGrid
|
||||
title="Активные сессии"
|
||||
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
|
||||
rows={sessionRows}
|
||||
columns={sessionCols}
|
||||
getRowId={(r) => r.id}
|
||||
selectable={false}
|
||||
emptyMessage="Нет активных сессий."
|
||||
rowMenu={(r) => ({ toggleLabel: 'Завершить сессию', onToggle: () => revoke.mutate(r.id) })}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{tab === 'history' ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs
|
||||
value={resultFilter}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'all' || v === 'success' || v === 'failed') {
|
||||
setResultFilter(v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" className="h-auto gap-4 border-0">
|
||||
<TabsTrigger value="all" className="px-0 pb-1 text-sm">
|
||||
Все
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="success" className="px-0 pb-1 text-sm">
|
||||
Успешные
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed" className="px-0 pb-1 text-sm">
|
||||
Неудачи
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => {
|
||||
if (value) setRange(value as AuditRange)
|
||||
}}
|
||||
items={[...RANGE_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : (
|
||||
<DataGrid
|
||||
i18n={DATA_GRID_RU}
|
||||
table={historyTable}
|
||||
recordCount={filtered.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame stacked dense>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Журнал входов</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<FrameFooter>
|
||||
<DataGridPagination sizes={[10, 20, 30]} />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)}
|
||||
</div>
|
||||
) : sessionsError ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{sessionsError instanceof ApiError
|
||||
? sessionsError.message
|
||||
: 'Ошибка загрузки'}
|
||||
</p>
|
||||
<PortalSheet
|
||||
open={event !== null}
|
||||
onOpenChange={(o) => !o && setEvent(null)}
|
||||
title="Событие входа"
|
||||
description="Детали записи журнала"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEvent(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetchSessions()}
|
||||
disabled={!event?.entry.actor_user_id}
|
||||
onClick={() => {
|
||||
const id = event?.entry.actor_user_id
|
||||
setEvent(null)
|
||||
if (id) void navigate({ to: '/admin/users/$userId', params: { userId: id } })
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
Открыть пользователя
|
||||
</Button>
|
||||
</div>
|
||||
) : sessionsLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : (
|
||||
<DataGrid
|
||||
i18n={DATA_GRID_RU}
|
||||
table={sessionsTable}
|
||||
recordCount={sessions.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame stacked dense>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активные сессии</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<FrameFooter>
|
||||
<DataGridPagination sizes={[10, 20, 30]} />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{event ? (
|
||||
<>
|
||||
<div>
|
||||
<StatusBadge tone={event.tone}>{event.status}</StatusBadge>
|
||||
</div>
|
||||
<KvList
|
||||
items={[
|
||||
['Логин', event.login],
|
||||
['IP', event.ip],
|
||||
['Клиент', event.client],
|
||||
['Приложение', event.app],
|
||||
['Причина', event.reason],
|
||||
]}
|
||||
/>
|
||||
<CodeBlock label="Событие">{`action: ${event.entry.action}\nsource_app: ${event.entry.source_app}\nseverity: ${event.entry.severity}\nip: ${event.ip}\nat: ${event.entry.created_at}`}</CodeBlock>
|
||||
</>
|
||||
) : null}
|
||||
</PortalSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,345 +1,233 @@
|
||||
/**
|
||||
* Admin OIDC clients — Frame surface.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/sheet-8
|
||||
* Docs: https://reui.io/blocks
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PlusIcon, KeyRoundIcon, Trash2Icon, CopyIcon } from 'lucide-react'
|
||||
import { CopyIcon, KeyRoundIcon, LinkIcon, PlusIcon, PowerIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
createOidcClient,
|
||||
deleteOidcClient,
|
||||
oidcClientsQueryOptions,
|
||||
oidcMetaQueryOptions,
|
||||
rotateOidcClientSecret,
|
||||
} from '@/queries/oidc'
|
||||
import { createOidcClient, deleteOidcClient, oidcClientsQueryOptions, oidcMetaQueryOptions, rotateOidcClientSecret } from '@/queries/oidc'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/oidc')({
|
||||
component: AdminOidcPage,
|
||||
})
|
||||
|
||||
function copy(value: string) {
|
||||
void navigator.clipboard.writeText(value)
|
||||
toast.success('Скопировано')
|
||||
}
|
||||
|
||||
function AdminOidcPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: clients, isLoading, isError, error } = useQuery(
|
||||
oidcClientsQueryOptions,
|
||||
)
|
||||
const { data: clients = [], isLoading, isError, error, refetch } = useQuery(oidcClientsQueryOptions)
|
||||
const { data: meta } = useQuery(oidcMetaQueryOptions)
|
||||
const [tab, setTab] = useState('clients')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<OidcClientPublic | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<OidcClientPublic | null>(null)
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
||||
const fail = (fallback: string) => (err: unknown) => toast.error(err instanceof ApiError ? err.message : fallback)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createOidcClient,
|
||||
onSuccess: (created) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
||||
void invalidate()
|
||||
setCreateOpen(false)
|
||||
setSecretOnce(created)
|
||||
toast.success('OIDC-клиент создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
|
||||
)
|
||||
},
|
||||
onError: fail('Не удалось создать клиента'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOidcClient,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
||||
setDeleteId(null)
|
||||
void invalidate()
|
||||
setDeleteTarget(null)
|
||||
setSelected(null)
|
||||
toast.success('Клиент удалён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось удалить',
|
||||
)
|
||||
},
|
||||
onError: fail('Не удалось удалить'),
|
||||
})
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: rotateOidcClientSecret,
|
||||
onSuccess: (created) => {
|
||||
setSecretOnce(created)
|
||||
toast.success('Секрет обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
|
||||
)
|
||||
},
|
||||
onError: fail('Не удалось обновить секрет'),
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Внешние сервисы (Technitium DNS и др.) через OpenID Connect
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
const columns = useMemo<GridColumn<OidcClientPublic>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
title: 'Клиент',
|
||||
size: 280,
|
||||
sortValue: (c) => c.name,
|
||||
cell: (c) => <TwoLineCell dot={c.enabled ? 'ok' : 'wn'} title={c.name} sub={c.client_id} />,
|
||||
},
|
||||
{ id: 'status', title: 'Статус', size: 120, cell: (c) => <StatusBadge tone={c.enabled ? 'ok' : 'wn'}>{c.enabled ? 'enabled' : 'disabled'}</StatusBadge> },
|
||||
{ id: 'redirects', title: 'Redirect URI', size: 340, cell: (c) => <span className="font-mono text-[12.5px]">{c.redirect_uris[0] ?? '—'}{c.redirect_uris.length > 1 ? ` +${c.redirect_uris.length - 1}` : ''}</span> },
|
||||
{ id: 'scopes', title: 'Scopes', size: 200, cell: (c) => <span className="font-mono text-[12.5px]">{c.scopes.join(' ')}</span> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
{meta ? (
|
||||
<Frame dense className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Issuer</FrameTitle>
|
||||
<FrameDescription>
|
||||
Metadata для Relying Party
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2 text-sm">
|
||||
<CopyRow label="Issuer" value={meta.issuer} />
|
||||
<CopyRow label="Discovery" value={meta.discovery_url} />
|
||||
<CopyRow label="JWKS" value={meta.jwks_url} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
const enabled = clients.filter((c) => c.enabled).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="OIDC-клиенты"
|
||||
sub="Внешние сервисы (Technitium DNS и др.) через OpenID Connect · Authorization Code"
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon /> Добавить клиента
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{isError ? (
|
||||
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||
<span>{error instanceof ApiError ? error.message : 'Не удалось загрузить клиентов.'}</span>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Frame dense className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Клиенты</FrameTitle>
|
||||
<FrameDescription>
|
||||
Confidential clients (Authorization Code)
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('all', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'confidential' }),
|
||||
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'enabled' }),
|
||||
kpi('off', 'Отключено', String(clients.length - enabled), { icon: ShieldCheckIcon, tone: clients.length - enabled ? 'wn' : 'neutral', badge: 'disabled' }),
|
||||
kpi('iss', 'Issuer', meta ? 'готов' : '—', { icon: LinkIcon, badge: 'discovery + JWKS' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
|
||||
<PageTabsList>
|
||||
<PageTab value="clients" label="Клиенты" count={clients.length} />
|
||||
<PageTab value="issuer" label="Issuer" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="clients" className="min-w-0">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError
|
||||
? error.message
|
||||
: 'Не удалось загрузить'}
|
||||
</p>
|
||||
) : !clients?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS.
|
||||
</p>
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{clients.map((c) => (
|
||||
<OidcClientRow
|
||||
key={c.id}
|
||||
client={c}
|
||||
onRotate={() => rotateMutation.mutate(c.id)}
|
||||
onDelete={() => setDeleteId(c.id)}
|
||||
rotating={rotateMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<SectionGrid
|
||||
title="Клиенты"
|
||||
description="Клик по строке — секрет, redirect URI и удаление"
|
||||
rows={clients}
|
||||
columns={columns}
|
||||
getRowId={(c) => c.id}
|
||||
searchText={(c) => `${c.name} ${c.client_id} ${c.redirect_uris.join(' ')}`}
|
||||
selectable={false}
|
||||
views={[
|
||||
{ label: 'Все', match: () => true },
|
||||
{ label: 'Включённые', match: (c) => c.enabled },
|
||||
{ label: 'Отключённые', match: (c) => !c.enabled },
|
||||
]}
|
||||
onRowClick={setSelected}
|
||||
emptyMessage="Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS."
|
||||
/>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<CreateOidcClientSheet
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
pending={createMutation.isPending}
|
||||
onSubmit={(values) => createMutation.mutate(values)}
|
||||
/>
|
||||
|
||||
<SecretRevealDialog
|
||||
client={secretOnce}
|
||||
onClose={() => setSecretOnce(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteId != null}
|
||||
onOpenChange={(o: boolean) => {
|
||||
if (!o) setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить OIDC-клиент?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Relying Party перестанет получать токены с этим client_id.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (deleteId) deleteMutation.mutate(deleteId)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0">{label}</span>
|
||||
<code className="bg-muted truncate rounded px-2 py-1 text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={`Копировать ${label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(value)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OidcClientRow({
|
||||
client,
|
||||
onRotate,
|
||||
onDelete,
|
||||
rotating,
|
||||
}: {
|
||||
client: OidcClientPublic
|
||||
onRotate: () => void
|
||||
onDelete: () => void
|
||||
rotating: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="border-border flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{client.name}</span>
|
||||
{client.enabled ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
enabled
|
||||
</Badge>
|
||||
</TabsContent>
|
||||
<TabsContent value="issuer">
|
||||
<FormFrame title="Issuer" description="Metadata для Relying Party" badge="OpenID Connect">
|
||||
{meta ? (
|
||||
<>
|
||||
{(
|
||||
[
|
||||
['Issuer', meta.issuer],
|
||||
['Discovery', meta.discovery_url],
|
||||
['JWKS', meta.jwks_url],
|
||||
] as const
|
||||
).map(([label, value]) => (
|
||||
<SettingRow key={label} title={label} description={value}>
|
||||
<Button variant="outline" onClick={() => copy(value)} aria-label={`Копировать ${label}`}>
|
||||
<CopyIcon /> Копировать
|
||||
</Button>
|
||||
</SettingRow>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
disabled
|
||||
</Badge>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
)}
|
||||
</div>
|
||||
<code className="text-muted-foreground text-xs">
|
||||
{client.client_id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={rotating}
|
||||
onClick={onRotate}
|
||||
>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
Секрет
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
|
||||
{client.redirect_uris.map((u) => (
|
||||
<span key={u}>{u}</span>
|
||||
))}
|
||||
<span>scopes: {client.scopes.join(' ')}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</FormFrame>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
function SecretRevealDialog({
|
||||
client,
|
||||
onClose,
|
||||
}: {
|
||||
client: OidcClientCreated | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const open = client != null
|
||||
const text = useMemo(() => {
|
||||
if (!client) return ''
|
||||
return `client_id: ${client.client_id}\nclient_secret: ${client.client_secret}`
|
||||
}, [client])
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(o: boolean) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Секрет показывается только один раз. Вставьте его в Technitium SSO.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{client ? (
|
||||
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
<PortalSheet
|
||||
open={selected !== null}
|
||||
onOpenChange={(o) => !o && setSelected(null)}
|
||||
title={selected?.name ?? 'Клиент'}
|
||||
description="OIDC-клиент"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selected || rotateMutation.isPending}
|
||||
onClick={() => selected && rotateMutation.mutate(selected.id)}
|
||||
>
|
||||
<KeyRoundIcon /> Новый секрет
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => selected && setDeleteTarget(selected)}>
|
||||
Удалить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<>
|
||||
<div>
|
||||
<StatusBadge tone={selected.enabled ? 'ok' : 'wn'}>{selected.enabled ? 'enabled' : 'disabled'}</StatusBadge>
|
||||
</div>
|
||||
<KvList items={[['client_id', selected.client_id], ['Scopes', selected.scopes.join(' ')]]} />
|
||||
<CodeBlock label="Redirect URI">{selected.redirect_uris.join('\n')}</CodeBlock>
|
||||
</>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Закрыть</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
Копировать
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PortalSheet>
|
||||
|
||||
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
|
||||
|
||||
<PortalSheet
|
||||
open={secretOnce !== null}
|
||||
onOpenChange={(o) => !o && setSecretOnce(null)}
|
||||
title="Сохраните client_secret"
|
||||
description="Секрет показывается только один раз · вставьте его в Technitium SSO"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSecretOnce(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button onClick={() => secretOnce && copy(`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`)}>
|
||||
<CopyIcon /> Копировать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{secretOnce ? <CodeBlock label="Учётные данные">{`client_id: ${secretOnce.client_id}\nclient_secret: ${secretOnce.client_secret}`}</CodeBlock> : null}
|
||||
</PortalSheet>
|
||||
|
||||
<TypedConfirm
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
title="Удалить OIDC-клиент?"
|
||||
description="Relying Party перестанет получать токены с этим client_id."
|
||||
expected={deleteTarget?.name ?? 'удалить'}
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
CloudIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
LayoutGridIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppAuthMode, AppId } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
|
||||
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
|
||||
import { kpi } from '@/components/portal/kpi'
|
||||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { ssoOpenApp } from '@/lib/auth'
|
||||
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
|
||||
|
||||
@@ -35,93 +36,106 @@ const APP_ICONS: Record<AppId, typeof CloudIcon> = {
|
||||
mm: LayoutDashboardIcon,
|
||||
}
|
||||
|
||||
async function openApp(
|
||||
_appId: AppId,
|
||||
baseUrl: string,
|
||||
authMode: AppAuthMode = 'jwt',
|
||||
) {
|
||||
async function openApp(baseUrl: string, authMode: AppAuthMode = 'jwt') {
|
||||
try {
|
||||
await ssoOpenApp(baseUrl, authMode)
|
||||
} catch {
|
||||
toast.error('Не удалось выпустить токен', { description: 'Открываем приложение по базовому адресу — войдите в нём отдельно.' })
|
||||
window.location.href = baseUrl.replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
function AppsPage() {
|
||||
const { data: me, isLoading: meLoading } = useQuery(meQueryOptions)
|
||||
const { data: catalog, isLoading: catalogLoading } = useQuery(
|
||||
catalogQueryOptions,
|
||||
)
|
||||
const isLoading = meLoading || catalogLoading
|
||||
const allowed = new Set(me?.apps ?? [])
|
||||
const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id))
|
||||
const { data: catalog, isLoading: catalogLoading, isError, refetch } = useQuery(catalogQueryOptions)
|
||||
|
||||
if (meLoading || catalogLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Приложения" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const all = catalog?.apps ?? []
|
||||
const allowedIds = new Set(me?.apps ?? [])
|
||||
const allowed = all.filter((a) => allowedIds.has(a.id))
|
||||
const denied = all.filter((a) => !allowedIds.has(a.id))
|
||||
const modeOf = (a: (typeof all)[number]): AppAuthMode => a.authMode ?? (a.id === 'dns' ? 'oidc' : 'jwt')
|
||||
|
||||
const toCard = (app: (typeof all)[number], open: boolean): AppCardItem => {
|
||||
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
|
||||
const meta = `${app.url.replace(/^https?:\/\//, '')} · ${modeOf(app) === 'oidc' ? 'OIDC' : 'JWT'}`
|
||||
return {
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
desc: app.description,
|
||||
badge: open ? 'Открыть' : 'Нет доступа',
|
||||
badgeVariant: open ? 'info-light' : 'outline',
|
||||
meta,
|
||||
icon: <Icon />,
|
||||
onSelect: () =>
|
||||
open
|
||||
? void openApp(app.url, modeOf(app))
|
||||
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Открыть приложение с текущей сессией (SSO handoff).
|
||||
</p>
|
||||
</div>
|
||||
{me?.is_admin ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/admin/apps" />}
|
||||
nativeButton={false}
|
||||
>
|
||||
Ссылки приложений
|
||||
<>
|
||||
<PageHead
|
||||
title="Приложения"
|
||||
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
|
||||
actions={
|
||||
me?.is_admin ? (
|
||||
<Button variant="outline" render={<Link to="/admin/apps" />} nativeButton={false}>
|
||||
Ссылки приложений
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{isError ? (
|
||||
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
|
||||
<span>Не удалось загрузить каталог приложений. Повторите запрос.</span>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="flex flex-col items-start gap-3 py-10">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className="size-10.5 text-muted-foreground"
|
||||
>
|
||||
<LayoutGridIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">Нет доступных приложений</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Обратитесь к администратору, чтобы получить доступ.
|
||||
</p>
|
||||
) : null}
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${all.length}` }),
|
||||
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => modeOf(a) === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
|
||||
kpi('oidc', 'OIDC', String(allowed.filter((a) => modeOf(a) === 'oidc').length), { icon: BadgeCheckIcon, badge: 'внешние RP' }),
|
||||
kpi('role', 'Роль', me?.is_admin ? 'Админ' : 'Пользователь', { icon: ShieldIcon, badge: me?.is_admin ? 'is_admin' : 'доступ по правам' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="all" className="min-w-0">
|
||||
<PageTabsList>
|
||||
<PageTab value="all" label="Доступные" count={allowed.length} />
|
||||
<PageTab value="no" label="Без доступа" count={denied.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="all" className="min-w-0">
|
||||
{allowed.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col gap-1 rounded-xl border border-dashed px-4 py-10 text-sm">
|
||||
<b className="text-foreground font-medium">Нет доступных приложений</b>
|
||||
Обратитесь к администратору, чтобы получить доступ.
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : (
|
||||
<QuickActionGrid
|
||||
title=""
|
||||
actions={apps.map((app) => {
|
||||
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
|
||||
return {
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
description: app.description,
|
||||
badgeLabel: 'Открыть',
|
||||
icon: <Icon />,
|
||||
onSelect: () =>
|
||||
void openApp(
|
||||
app.id,
|
||||
app.url,
|
||||
app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'),
|
||||
),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
) : (
|
||||
<AppCards
|
||||
title="Мои приложения"
|
||||
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
|
||||
items={allowed.map((a) => toCard(a, true))}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="no" className="min-w-0">
|
||||
<AppCards title="Недоступные приложения" description="Права не выданы · запросите у администратора" items={denied.map((a) => toCard(a, false))} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
import { PortalShell } from '@/components/layout/portal-shell'
|
||||
import {
|
||||
clearToken,
|
||||
getToken,
|
||||
@@ -36,8 +36,8 @@ export const Route = createFileRoute('/_auth')({
|
||||
|
||||
function AuthLayout() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PortalShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
</PortalShell>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user