6 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 0948455d15 fix(web): привести таблицы к виду MikrotikManager — тулбар, сегменты, шапка и ячейки
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m12s
CD / publish (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:43:46 +07:00
DenozordecandClaude Sonnet 5.5 e7b34ce6ae fix(web): вернуть нативные токены ReUI и добавить шапку страницы пользователей
quality / changes (push) Successful in 6s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 44s
CD / quality (push) Successful in 52s
CD / publish (push) Successful in 1m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:33:21 +07:00
DenozordecandClaude Sonnet 5.5 9b05fcf03a feat(web): перенести OIDC, ссылки приложений и пользователей на новый дизайн
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:09:04 +07:00
DenozordecandClaude Sonnet 5.5 69b3553f6d feat(web): перенести страницы Приложения, Аккаунт, Входы и Изменения на новый дизайн
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:08:10 +07:00
DenozordecandClaude Sonnet 5.5 148a778ba1 feat(web): оболочка портала на блоках макета — меню, шапка, монитор, палитра ⌘K
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:59:35 +07:00
DenozordecandClaude Sonnet 5.5 6898d2255d refactor(web): вынести общие блоки макета в components/portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:53:59 +07:00
46 changed files with 2486 additions and 1620 deletions

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,
@@ -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
}
+569
View File
@@ -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>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
@@ -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>
</>
)
}
+45
View File
@@ -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.
@@ -1,10 +1,9 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
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 {
@@ -17,7 +16,6 @@ import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
@@ -26,14 +24,24 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
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 {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
@@ -83,7 +91,7 @@ const ACTIONS_COLUMN_SIZE = 120
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function PreviewSectionGrid<T extends object>({
export function SectionGrid<T extends object>({
title,
description,
rows,
@@ -101,8 +109,7 @@ export function PreviewSectionGrid<T extends object>({
rowMenu,
onReorder,
rowStatus,
pageSize = 8,
footer,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
@@ -194,20 +201,22 @@ export function PreviewSectionGrid<T extends object>({
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
for (const col of visibleColumns) {
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title },
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
}
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
@@ -242,6 +251,7 @@ export function PreviewSectionGrid<T extends object>({
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
@@ -277,6 +287,8 @@ export function PreviewSectionGrid<T extends object>({
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
@@ -285,82 +297,72 @@ export function PreviewSectionGrid<T extends object>({
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</div>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : null}
{searchText ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder="Поиск"
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem
key={c.id}
checked={!hidden[c.id]}
onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}
>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none">
{views ? (
<Tabs
value={String(view)}
onValueChange={(v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto px-4">
{views.map((v, i) => (
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
{v.label} · {rows.filter(v.match).length}
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
@@ -461,24 +463,13 @@ export function PreviewSectionGrid<T extends object>({
</ul>
) : (
<DataGridScrollArea>
<div className="pb-3" style={{ minWidth }}>
{onReorder ? (
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
) : (
<DataGridTable />
)}
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs">
{footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
</span>
{paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
@@ -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'
@@ -44,6 +44,13 @@ import {
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
import { cellPad } from '@/components/portal/data-page-layout'
import {
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from '@/components/portal/data-page-layout'
import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page'
import { cn } from '@authportal/ui/lib/utils'
import { Filters } from '@/components/reui/filters/filters'
import {
createFilterQuery,
@@ -54,14 +61,6 @@ import type {
FilterField,
FilterQuery,
} from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogAction,
@@ -93,7 +92,6 @@ import {
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Separator } from '@authportal/ui/components/separator'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
@@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) {
}).format(date)
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? (
<Badge variant="info-outline">Админ</Badge>
@@ -710,15 +699,6 @@ export function AdminUsersGrid({
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
const stats = useMemo(
() => ({
total: users.length,
admins: users.filter((u) => u.is_admin).length,
disabled: users.filter((u) => u.disabled).length,
}),
[users],
)
const filterFields: FilterField[] = useMemo(
() => [
{
@@ -778,6 +758,16 @@ export function AdminUsersGrid({
onDeactivate,
onDelete,
onResetPasskeys,
}).map((col, i, all) => {
const pad = cellPad(i, all.length)
return {
...col,
meta: {
...col.meta,
headerClassName: cn(col.meta?.headerClassName, pad),
cellClassName: cn(col.meta?.cellClassName, pad),
},
}
}),
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
)
@@ -850,38 +840,17 @@ export function AdminUsersGrid({
: undefined
}
tableLayout={{
...DATA_GRID_LAYOUT,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
dense: true,
}}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Пользователи
</FrameTitle>
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
<span>{stats.total} учётных записей</span>
<DotSeparator />
<span>{stats.admins} админов</span>
{stats.disabled > 0 ? (
<>
<DotSeparator />
<span>{stats.disabled} отключено</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<DataPageCard>
<DataPageToolbar
filterControl={
<Filters
query={query}
fields={filterFields}
@@ -890,27 +859,18 @@ export function AdminUsersGrid({
operatorLabels={FILTER_OPERATORS_RU}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
<FilterIcon aria-hidden />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
}
countLabel={`${filteredData.length} пользователей`}
actions={
<>
{showClearButton ? (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={handleClearFilters}
disabled={gridLoading}
>
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
<FunnelXIcon aria-hidden />
Сбросить
</Button>
@@ -918,42 +878,34 @@ export function AdminUsersGrid({
<DataGridColumnVisibility
table={table}
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Настройки таблицы"
>
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
<Settings2Icon aria-hidden="true" />
Колонки
</Button>
}
/>
</div>
</div>
<Separator />
{selectedCount > 0 ? (
<>
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
<Separator />
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
}
/>
{selectedCount > 0 ? (
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
</DataPageCard>
</DataGrid>
</TooltipProvider>
)
@@ -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 -3
View File
@@ -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>
)
}
@@ -1,145 +0,0 @@
@import '@fontsource-variable/geist';
@import '@fontsource-variable/geist-mono';
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
html[data-preview-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 {
gap: 0.75rem;
}
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] {
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.205 0.01 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.205 0.01 264);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.205 0.01 264);
--secondary: oklch(0.955 0.005 264);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0.006 264);
--muted-foreground: oklch(0.44 0.02 264);
--accent: oklch(0.93 0.01 264);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.88 0.008 264);
--input: oklch(0.86 0.008 264);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264);
--sidebar-foreground: oklch(0.205 0.01 264);
--sidebar-accent: oklch(0.93 0.01 264);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.88 0.008 264);
--invert: oklch(0.9 0.01 264);
--sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163);
--success-foreground: oklch(0.4 0.1 163);
--warning: oklch(0.75 0.16 80);
--warning-foreground: oklch(0.45 0.1 70);
--info: oklch(0.55 0.23 293);
--info-foreground: oklch(0.42 0.2 293);
}
html[data-preview-density].dark {
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--secondary: oklch(0.252 0.01 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.01 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--invert: oklch(0.37 0.013 285.805);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325);
--success: oklch(0.696 0.17 162.48);
--success-foreground: oklch(0.596 0.145 163.225);
--warning: oklch(0.795 0.184 86.047);
--warning-foreground: oklch(0.681 0.162 75.834);
--info: oklch(0.606 0.25 292.717);
--info-foreground: oklch(0.541 0.281 293.009);
}
html[data-preview-density][data-preview-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'] {
--background: oklch(0 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--sidebar: oklch(0.1 0 0);
--foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 150);
--warning: oklch(0.9 0.17 95);
--warning-foreground: oklch(0.95 0.12 95);
--destructive: oklch(0.78 0.2 25);
--destructive-foreground: oklch(0.88 0.12 25);
--info: oklch(0.8 0.15 300);
--info-foreground: oklch(0.9 0.1 300);
--card: oklch(0.12 0 0);
--popover: oklch(0.1 0 0);
--muted: oklch(0.22 0 0);
--muted-foreground: oklch(0.86 0 0);
--border: oklch(1 0 0 / 55%);
--input: oklch(1 0 0 / 60%);
--ring: oklch(0.85 0.17 95);
}
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
html[data-preview-density] {
--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 {
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 {
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'] * {
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) {
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']) {
background: oklch(0 0 0 / 55%);
backdrop-filter: none;
}
+2 -1
View File
@@ -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>
</>
)
}
+10 -10
View File
@@ -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 }) {
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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}
+1
View File
@@ -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: {
+23
View File
@@ -0,0 +1,23 @@
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
padding-block: 0.25rem;
}
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
gap: 0.75rem;
}
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
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 #main-content *,
html [data-slot='sidebar-wrapper'] * {
animation: none !important;
transition: none !important;
}
}
+270 -15
View File
@@ -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)}
/>
</>
)
}
+4 -9
View File
@@ -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>
</>
)
}
+242 -73
View File
@@ -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()}
/>
</>
)
}
+19 -3
View File
@@ -6,8 +6,11 @@ import { useCallback, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { ShieldCheckIcon, UserCheckIcon, UserXIcon, UsersIcon } from 'lucide-react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { PageHead } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
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'
@@ -174,8 +177,21 @@ function AdminUsersPage() {
[invalidateUsers],
)
const admins = users.filter((u) => u.is_admin).length
const disabled = users.filter((u) => u.disabled).length
return (
<PageShell>
<>
<PageHead title="Пользователи" sub="Учётные записи портала · роли, доступ к приложениям, passkeys" />
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'учётных записей' }),
kpi('adm', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'ok', badge: 'is_admin' }),
kpi('act', 'Активных', String(users.length - disabled), { icon: UserCheckIcon, badge: 'могут войти' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, tone: disabled ? 'wn' : 'neutral', badge: 'disabled' }),
]}
/>
{error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
@@ -229,6 +245,6 @@ function AdminUsersPage() {
if (!next) setAuditUser(null)
}}
/>
</PageShell>
</>
)
}
+269 -496
View File
@@ -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>
</>
)
}
+180 -292
View File
@@ -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)}
/>
</>
)
}
+95 -81
View File
@@ -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>
</>
)
}
+3 -3
View File
@@ -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>
)
}