26 Commits
Author SHA1 Message Date
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
DenozordecandClaude Sonnet 5.5 b89e72ec1b fix(design-preview): плитки монитора системы не разъезжаются
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 46s
CD / quality (push) Successful in 56s
CD / publish (push) Successful in 1m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:14:13 +07:00
DenozordecandClaude Sonnet 5.5 41d9325d14 fix(design-preview): убрать плашку «Обновлено · Live · авто» из шапки
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:13:16 +07:00
DenozordecandClaude Sonnet 5.5 f6466d9d7c revert(design-preview): вернуть компоновку каркаса как в MikrotikManager v2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:11:28 +07:00
DenozordecandClaude Sonnet 5.5 609bea6ad7 feat(design-preview): каркас на блоке app-shell-12 из ReUI PRO
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:03:55 +07:00
DenozordecandClaude Sonnet 5.5 f97604c23e fix(design-preview): вкладки line как в ReUI — без сплошной нижней линии
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:57:16 +07:00
DenozordecandClaude Sonnet 5.5 1395e2a70b fix(design-preview): убрать двойную линию под вкладками окна прав
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:54:32 +07:00
DenozordecandClaude Sonnet 5.5 a07cb0e55a feat(design-preview): иконки в карточках KPI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:54:54 +07:00
DenozordecandClaude Sonnet 5.5 c8d69bbdfa fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:37:40 +07:00
DenozordecandClaude Sonnet 5.5 700ad508d8 fix(design-preview): правки по ревью — пороги, высоты вкладок, контраст фокуса, счётчик вкладок
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:57:22 +07:00
DenozordecandClaude Sonnet 5.5 b82c8031c2 fix(design-preview): размеры, отступы и пороги ширины как на досках Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:53:25 +07:00
DenozordecandClaude Sonnet 5.5 3e1ad15ba3 fix(design-preview): палитра MikrotikManager v2 для макета Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:43:30 +07:00
DenozordecandClaude Sonnet 5.5 1b89135186 fix(design-preview): окна создания, подписи и кнопки как на досках Auth Portal
Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности
всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:44:19 +07:00
DenozordecandClaude Sonnet 5.5 cd6516edd6 fix(design-preview): полоса KPI, баннеры и шапка точно как на досках Auth Portal
Полоса показателей — один Frame с линиями и плашками, колонки по порогам контейнера; баннер в одну строку
с «Объяснить»; бренд «Единый вход · SSO», заголовки групп капсом; пороги монитора в шапке как на доске.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:41:00 +07:00
DenozordecandClaude Sonnet 5.5 afbc6fc366 feat(design-preview): перенос досок Auth Portal 1 к 1 — таблицы, вкладки, формы, права
Таблицы по анатомии доски: «Таблица/Карточки», поиск, «Колонки», виды линией со счётчиками, панель
массовых действий, переключатель и меню «⋮» в строке. Вкладки страниц — линия с мелким счётчиком.
Строки настроек (пароль, ротация журнала, меню), «Объяснить» в баннерах с панелью разбора,
палитра с ассистентом и OIDC-клиентами, сворачиваемые группы меню, плашка «Обновлено · Live»,
данные досок «Изменения» и «Доступ по приложениям», права доступа вкладками.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:35:45 +07:00
DenozordecandClaude Sonnet 5.5 709dd93548 fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса
Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit
от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись.
На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:18:06 +07:00
DenozordecandClaude Sonnet 5.5 60e0b886fc fix(design-preview): плитки приложений внутри рамки и стиль MikrotikManager v2
QuickActionGrid получил autoFit: Frame с отступами и сетка auto-fit от 17rem, плитки больше не
вылетают за границы. Центр состояния в шапке, меню пользователя (тема, плотность, контраст),
точки проблем в меню, размытая шапка, высокий контраст и видимый фокус приведены к макету v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:15:12 +07:00
DenozordecandClaude Sonnet 5.5 8bbaab62e4 fix(design-preview): убрать страницу пользователя — дублировала панель прав
Права, сброс passkeys, отключение и удаление уже есть в панели прав и меню строки,
сессии и события — в «Входах» и «Изменениях». Страница откроется из коммита 007ece4.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:11:43 +07:00
DenozordecandClaude Sonnet 5.5 c8351424f4 feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки
администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:39:06 +07:00
DenozordecandClaude Sonnet 5.5 007ece4575 feat(design-preview): страница пользователя с рейкой вкладок (solution-users-2)
Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее
состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль»,
«Открыть профиль» в панели прав, хлебные крошки в шапке.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:35:57 +07:00
DenozordecandClaude Sonnet 5.5 f0a257220f feat(design-preview): права доступа — широкая боковая панель с двумя колонками
Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем
«Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении,
закрытие с правками спрашивает подтверждение.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:33:06 +07:00
DenozordecandClaude Sonnet 5.5 e02cfad0b1 feat(design-preview): пометки строк «Не сохранено» и «Будет удалено», массовое удаление с подтверждением
Строки с накопленными правками подсвечиваются и получают плашку (getRowStatus из DataGrid v9),
удаление применяется при «Сохранить», в каталоге пользователей добавлено «Удалить…» в панели
массовых действий.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:26:37 +07:00
52 changed files with 4385 additions and 2658 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>
)
}
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, CopyIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react' import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
@@ -20,6 +20,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog' } from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label' import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
@@ -32,7 +33,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@authportal/ui/components/sheet' } from '@authportal/ui/components/sheet'
import type { Tone } from './mock' import type { Tone } from './tone'
import { TONE_BADGE } from './tone' import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' } const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
@@ -59,10 +60,10 @@ export function PageHead({
actions?: ReactNode actions?: ReactNode
}) { }) {
return ( return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3"> <header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1> <h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p> <p className="text-muted-foreground text-sm">{sub}</p>
</div> </div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null} {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header> </header>
@@ -103,13 +104,15 @@ export function SignalAlert({
} }
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */ /** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PreviewSheet({ export function PortalSheet({
open, open,
onOpenChange, onOpenChange,
title, title,
description, description,
children, children,
footer, footer,
size = 'md',
dirty,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -117,8 +120,13 @@ export function PreviewSheet({
description: string description: string
children: ReactNode children: ReactNode
footer?: ReactNode footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) { }) {
const [touched, setTouched] = useState(false) const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false) const [guard, setGuard] = useState(false)
function requestChange(next: boolean) { function requestChange(next: boolean) {
@@ -136,7 +144,13 @@ export function PreviewSheet({
<SheetContent <SheetContent
side="right" side="right"
showCloseButton={false} showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(26rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none" className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
> >
<SheetHeader className="shrink-0 gap-0 p-0"> <SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4"> <div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
@@ -284,7 +298,7 @@ export function KvFrame({
items: Array<{ label: string; value: ReactNode }> items: Array<{ label: string; value: ReactNode }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -317,7 +331,7 @@ export function CopyList({
items: Array<{ label: string; value: string }> items: Array<{ label: string; value: string }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -329,19 +343,18 @@ export function CopyList({
key={item.label} key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0" className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
> >
<code className="min-w-0 flex-1 basis-64 font-mono text-[13px] break-all">{item.value}</code> <code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground basis-40 text-xs">{item.label}</span> <span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button <Button
type="button" type="button"
size="icon-sm" variant="outline"
variant="ghost"
aria-label={`Скопировать: ${item.label}`} aria-label={`Скопировать: ${item.label}`}
onClick={() => { onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined) void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`) toast.success(`${item.label} скопирован`)
}} }}
> >
<CopyIcon aria-hidden /> Копировать
</Button> </Button>
</li> </li>
))} ))}
@@ -398,3 +411,71 @@ export function Segmented({
</Tabs> </Tabs>
) )
} }
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
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 './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -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,83 @@
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, PortalSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
*/
export function ExplainBanner({
tone,
title,
children,
text,
}: {
tone: 'er' | 'wn'
title: string
/** Текст для панели разбора (то же, что в баннере). */
text: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
return (
<>
<div
role="status"
className={cn(
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
)}
>
<div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
<PortalSheet
open={open}
onOpenChange={setOpen}
title="Ассистент · разбор"
description="Черновик: проверьте перед применением"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Закрыть
</Button>
<Button
onClick={() => {
setOpen(false)
void navigate({ to: '/design-preview/logins' })
}}
>
Открыть журнал входов
</Button>
</>
}
>
<SignalAlert tone="in" title="Что случилось">
{title} {text}
</SignalAlert>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Вероятные причины</span>
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone="er">Подбор пароля</StatusBadge>
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
<StatusBadge tone="mu">Смена IP</StatusBadge>
</div>
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
</div>
<CodeBlock label="Что проверить">
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/sessions?user=ops@shnt.top'}
</CodeBlock>
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
</SignalAlert>
</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)} дн назад`
}
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@authportal/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -0,0 +1,497 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon, SearchIcon } 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 {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
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,
DropdownMenuCheckboxItem,
DropdownMenuContent,
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 './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function SectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 8,
footer,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
})
}
for (const col of visibleColumns) {
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title },
})
}
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
>
<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}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div className="pb-3" 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>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
@@ -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 = type BadgeVariant =
| 'success-light' | 'success-light'
@@ -19,6 +23,6 @@ export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success', ok: 'bg-success',
wn: 'bg-warning', wn: 'bg-warning',
er: 'bg-destructive', er: 'bg-destructive',
in: 'bg-info', in: 'bg-success',
mu: 'bg-muted-foreground/50', mu: 'bg-muted-foreground/50',
} }
@@ -16,4 +16,3 @@ export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet' export { CreateUserSheet } from './create-user-sheet'
export { CreateOidcClientSheet } from './create-oidc-client-sheet' export { CreateOidcClientSheet } from './create-oidc-client-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid' 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>
</>
)
}
@@ -33,6 +33,8 @@ interface QuickActionGridProps {
title?: string title?: string
description?: string description?: string
className?: string className?: string
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
autoFit?: boolean
} }
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
@@ -108,18 +110,24 @@ export function QuickActionGrid({
title = 'Быстрые действия', title = 'Быстрые действия',
description, description,
className, className,
autoFit = false,
}: QuickActionGridProps) { }: QuickActionGridProps) {
if (actions.length === 0) return null if (actions.length === 0) return null
return ( return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}> <Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && ( {(title || description) && (
<FrameHeader> <FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null} {title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null} {description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader> </FrameHeader>
)} )}
<div className={cn('grid gap-2', kpiCols(actions.length))}> <div
className={cn(
'grid gap-2',
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
)}
>
{actions.map((action) => ( {actions.map((action) => (
<FramePanel <FramePanel
key={action.id} key={action.id}
@@ -1,196 +0,0 @@
import { useMemo, useState } from 'react'
import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AdminUser,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APPS } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} 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 { diffAccess, type AccessValue } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
type Level = 'none' | PermissionAction
const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет доступа',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
function levelOf(perms: readonly string[], appId: AppId, sectionId: string, actions: PermissionAction[]): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) {
return action
}
}
return 'none'
}
/** Ключи для уровня: иерархия админ ⊃ запись ⊃ чтение внутри раздела. */
function keysForLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
const upto = order.indexOf(level)
return order
.slice(0, upto + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Окно «Права доступа»: вкладки по приложениям и разделам, локальное состояние, без API. */
export function AccessDialog({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [apps, setApps] = useState<AppId[]>(user.apps)
const [permissions, setPermissions] = useState<string[]>(user.permissions)
const next = useMemo<AccessValue>(() => ({ apps, permissions }), [apps, permissions])
const lines = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, next), [user, next])
function setLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level) {
setPermissions((prev) => {
const prefix = `${appId}:${sectionId}:`
const rest = prev.filter((k) => !k.startsWith(prefix))
return [...rest, ...keysForLevel(appId, sectionId, actions, level)].sort()
})
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[calc(100svh-2rem)] w-[min(52rem,calc(100vw-2rem))] max-w-none flex-col gap-4 sm:max-w-none">
<DialogHeader>
<DialogTitle>Права доступа · {user.email}</DialogTitle>
<DialogDescription>Настройка доступа к приложениям и разделам</DialogDescription>
</DialogHeader>
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="apps">Приложения</TabsTrigger>
{PERMISSION_CATALOG.map((c) => (
<TabsTrigger key={c.appId} value={c.appId}>
{APP_SHORT[c.appId]}
</TabsTrigger>
))}
<TabsTrigger value="journal">Журнал</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((app) => (
<li key={app.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<label htmlFor={`app-${app.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{app.title}</span>
<span className="text-muted-foreground text-xs">{app.description}</span>
</label>
<Switch
id={`app-${app.id}`}
checked={apps.includes(app.id)}
onCheckedChange={(on) =>
setApps((prev) => (on ? [...prev, app.id] : prev.filter((x) => x !== app.id)))
}
/>
</li>
))}
</ul>
</TabsContent>
{PERMISSION_CATALOG.map((catalog) => (
<TabsContent key={catalog.appId} value={catalog.appId} className="min-h-0 overflow-y-auto">
{!apps.includes(catalog.appId) ? (
<p className="text-muted-foreground mb-2 text-xs">
Приложение отключено на вкладке «Приложения» — права разделов не действуют.
</p>
) : null}
<ul className="flex flex-col divide-y rounded-lg border">
{catalog.sections.map((section) => {
const level = levelOf(permissions, catalog.appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5">
<div className="flex min-w-0 flex-1 basis-48 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span>
</div>
<Select
value={level}
onValueChange={(v) => v && setLevel(catalog.appId, section.id, section.actions, v as Level)}
items={options.map((o) => ({ value: o, label: LEVEL_LABEL[o] }))}
>
<SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_LABEL[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</li>
)
})}
</ul>
</TabsContent>
))}
<TabsContent value="journal" className="min-h-0 overflow-y-auto">
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
<DialogFooter className="items-center">
<span className="text-muted-foreground mr-auto text-xs">
{lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={lines.length === 0}
onClick={() => {
onSave(next)
onClose()
}}
>
Сохранить права
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,125 @@
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} 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 '@/components/portal/bits'
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
const LEVEL_NAME: Record<Level, string> = { none: 'Нет доступа', read: 'Чтение', write: 'Запись', admin: 'Админ' }
/** Приложения, у которых на доске есть вкладка с разделами. */
const PERM_TABS: AppId[] = ['cfdm', 'vps', 'bgp', 'fw', 'mm']
/**
* Редактор прав по доске «Права доступа»: вкладки «Приложения», по приложению с разделами и «Журнал».
* Раздел — строка с названием, подсказкой и уровнем «Нет доступа / Чтение / Запись / Админ».
*/
export function AccessEditor({
user,
value,
onChange,
}: {
user: AdminUser
value: AccessValue
onChange: (next: AccessValue) => void
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList variant="line" className="h-10! w-full justify-start gap-5 overflow-x-auto">
<TabsTrigger value="apps" className="px-1 text-sm">
Приложения
</TabsTrigger>
{PERM_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="px-1 text-sm">
{id === 'vps' ? 'VPS Tracker' : APP_SHORT[id]}
</TabsTrigger>
))}
<TabsTrigger value="journal" className="px-1 text-sm">
Журнал
</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="mt-4 min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-3 px-3.5 py-3">
<label htmlFor={`acc-${a.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{a.title}</span>
<span className="text-muted-foreground text-xs">{a.description}</span>
</label>
<Switch
id={`acc-${a.id}`}
checked={value.apps.includes(a.id)}
onCheckedChange={(on) =>
onChange({ ...value, apps: on ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
}
/>
</li>
))}
</ul>
</TabsContent>
{PERM_TABS.map((appId) => {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
if (!catalog) return null
return (
<TabsContent key={appId} value={appId} className="mt-4 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-4">
{catalog.sections.map((section) => {
const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<Field key={section.id}>
<FieldLabel>{section.title}</FieldLabel>
<Select
value={level}
onValueChange={(v) =>
v && onChange({ ...value, permissions: withLevel(value.permissions, appId, section.id, section.actions, v as Level) })
}
items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))}
>
<SelectTrigger className="w-full" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_NAME[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}</FieldDescription>
</Field>
)
})}
</div>
</TabsContent>
)
})}
<TabsContent value="journal" className="mt-4 flex min-h-0 flex-col gap-3 overflow-y-auto">
<p className="text-muted-foreground text-sm">Последние события пользователя из журнала портала.</p>
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,53 @@
import { StatusBadge } from '@/components/portal/bits'
import type { Tone } from './mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
type MatrixRow = {
id: string
name: string
sub: string
tone: Tone
status: string
total: string
read: string
write: string
admin: string
}
/** Данные доски «Доступ по приложениям»: сколько пользователей имеет доступ и с каким уровнем. */
const ROWS: MatrixRow[] = [
{ id: 'cfdm', name: 'Cloudflare Domain Manager', sub: 'cfdm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '2', admin: '2' },
{ id: 'vps', name: 'VPS Tracker', sub: 'vps · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'bgp', name: 'EvoBGP', sub: 'bgp · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'fw', name: 'EvoFirewall', sub: 'fw · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'dns', name: 'Technitium DNS', sub: 'dns · OIDC', tone: 'ok', status: 'Выдан', total: '2', read: '0', write: '1', admin: '1' },
{ id: 'cdn', name: 'CDN Manager', sub: 'cdn · JWT', tone: 'wn', status: 'Мало админов', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'mm', name: 'MikrotikManager', sub: 'mm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
]
const mono = (v: string) => <span className="font-mono text-[12.5px]">{v}</span>
const COLUMNS: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.sub} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, cell: (r) => mono(r.total) },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => mono(r.read) },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => mono(r.write) },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => mono(r.admin) },
]
/** Матрица доступа по доске: сколько пользователей имеет доступ к каждому приложению и с каким уровнем. */
export function AccessMatrix() {
return (
<SectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={ROWS}
columns={COLUMNS}
getRowId={(r) => r.id}
views={[{ label: 'Все', match: () => true }]}
searchText={(r) => `${r.name} ${r.id}`}
bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }}
/>
)
}
+59 -1
View File
@@ -1,4 +1,10 @@
import type { AppId } from '@authportal/shared' import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APP_SHORT } from './mock' import { APP_SHORT } from './mock'
export type AccessValue = { apps: AppId[]; permissions: string[] } export type AccessValue = { apps: AppId[]; permissions: string[] }
@@ -11,3 +17,55 @@ export function diffAccess(before: AccessValue, after: AccessValue): string[] {
for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`) for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`)
return lines return lines
} }
export type Level = 'none' | 'read' | 'write' | 'admin'
export const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
/** Текущий уровень раздела: иерархия админ ⊃ запись ⊃ чтение. */
export function levelOf(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) return action
}
return 'none'
}
/** Ключи прав для уровня раздела. */
export function keysForLevel(appId: AppId, sectionId: string, actions: readonly PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
return order
.slice(0, order.indexOf(level) + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Сколько разделов приложения открыто (уровень выше «Нет»). */
export function grantCount(perms: readonly string[], appId: AppId): { granted: number; total: number } {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
const sections = catalog?.sections ?? []
const granted = sections.filter((s) => levelOf(perms, appId, s.id, s.actions) !== 'none').length
return { granted, total: sections.length }
}
/** Новое множество прав после смены уровня одного раздела. */
export function withLevel(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
level: Level,
): string[] {
const prefix = `${appId}:${sectionId}:`
return [...perms.filter((k) => !k.startsWith(prefix)), ...keysForLevel(appId, sectionId, actions, level)].sort()
}
@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react'
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 { PortalSheet } from '@/components/portal/bits'
/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */
export function AccessSheet({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [value, setValue] = useState<AccessValue>({ apps: user.apps, permissions: user.permissions })
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
return (
<PortalSheet
open
onOpenChange={(o) => !o && onClose()}
size="lg"
dirty={changes > 0}
title={`Права доступа · ${user.email}`}
description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение"
footer={
<>
<span className="text-muted-foreground mr-auto self-center text-xs">
{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={changes === 0}
onClick={() => {
onSave(value)
onClose()
}}
>
Сохранить права
</Button>
</>
}
>
<AccessEditor user={user} value={value} onChange={setValue} />
</PortalSheet>
)
}
@@ -0,0 +1,194 @@
import { useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
import { PortalSheet, SignalAlert } from '@/components/portal/bits'
/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */
export function CreateUserPortalSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateUserRequest) => void
}) {
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [admin, setAdmin] = useState(false)
const [touched, setTouched] = useState(false)
const passwordError = password.length < 12 ? 'Минимум 12 символов' : null
const emailError = !/^\S+@\S+\.\S+$/.test(email) ? 'Укажите email' : null
const valid = !passwordError && !emailError
function reset() {
setEmail('')
setName('')
setPassword('')
setAdmin(false)
setTouched(false)
}
return (
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(email || name || password || admin)}
title="Создать пользователя"
description="После создания откроются права доступа"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ email, name: name || email.split('@')[0]!, password, is_admin: admin, apps: [], permissions: [] })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(emailError)}>
<FieldLabel htmlFor="cu-email">
Email <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-email" type="email" value={email} placeholder="new.user@shnt.top" aria-invalid={touched && Boolean(emailError)} onChange={(e) => setEmail(e.target.value)} />
{touched && emailError ? <FieldError>{emailError}</FieldError> : <FieldDescription>Станет логином</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="cu-name">Имя</FieldLabel>
<Input id="cu-name" value={name} placeholder="Новый пользователь" onChange={(e) => setName(e.target.value)} />
</Field>
<Field data-invalid={touched && Boolean(passwordError)}>
<FieldLabel htmlFor="cu-pw">
Временный пароль <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-pw" type="password" value={password} aria-invalid={touched && Boolean(passwordError)} onChange={(e) => setPassword(e.target.value)} />
{touched && passwordError ? <FieldError>✕ {passwordError}</FieldError> : <FieldDescription>Пользователь заменит его на passkey</FieldDescription>}
</Field>
<Field orientation="horizontal">
<Switch id="cu-admin" checked={admin} onCheckedChange={setAdmin} />
<div className="flex flex-col">
<FieldLabel htmlFor="cu-admin">Администратор</FieldLabel>
<FieldDescription>Полный доступ к порталу и ко всем приложениям</FieldDescription>
</div>
</Field>
<SignalAlert tone="wn" title="Журнал">
Создание записывается в журнал «Изменения» с вашим именем и IP.
</SignalAlert>
</PortalSheet>
)
}
/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */
export function CreateOidcPortalSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateOidcClientValues) => void
}) {
const [name, setName] = useState('')
const [uris, setUris] = useState('')
const [enabled, setEnabled] = useState(true)
const [touched, setTouched] = useState(false)
const list = uris
.split('\n')
.map((x) => x.trim())
.filter(Boolean)
const urisError = list.length === 0 || list.some((u) => !/^https:\/\/[^\s*]+$/.test(u)) ? 'Укажите точный https-адрес колбэка приложения.' : null
const nameError = name.trim() ? null : 'Введите название'
const valid = !urisError && !nameError
function reset() {
setName('')
setUris('')
setEnabled(true)
setTouched(false)
}
return (
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(name || uris)}
title="Создать OIDC-клиент"
description="Секрет будет показан один раз после создания"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ name: name.trim(), redirect_uris: list, scopes: ['openid', 'profile', 'email'], enabled })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(nameError)}>
<FieldLabel htmlFor="co-name">
Название <span aria-hidden>*</span>
</FieldLabel>
<Input id="co-name" value={name} placeholder="Technitium DNS" aria-invalid={touched && Boolean(nameError)} onChange={(e) => setName(e.target.value)} />
{touched && nameError ? <FieldError>{nameError}</FieldError> : null}
</Field>
<Field data-invalid={touched && Boolean(urisError)}>
<FieldLabel htmlFor="co-uri">
Redirect URI <span aria-hidden>*</span>
</FieldLabel>
<Textarea
id="co-uri"
rows={4}
value={uris}
placeholder="https://dns.example.com/sso/callback"
aria-invalid={touched && Boolean(urisError)}
className="font-mono"
onChange={(e) => setUris(e.target.value)}
/>
{touched && urisError ? (
<FieldError>✕ {urisError}</FieldError>
) : (
<FieldDescription>По одному на строку · только https · точное совпадение</FieldDescription>
)}
</Field>
<Field orientation="horizontal">
<Switch id="co-on" checked={enabled} onCheckedChange={setEnabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="co-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</PortalSheet>
)
}
@@ -1,28 +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;
}
html[data-preview-contrast='high'] {
--muted-foreground: color-mix(in oklch, var(--foreground) 82%, var(--background));
--border: color-mix(in oklch, var(--foreground) 32%, var(--background));
--input: color-mix(in oklch, var(--foreground) 40%, var(--background));
}
/* Шрифты дизайн-системы (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);
}
-46
View File
@@ -1,46 +0,0 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import type { KpiStatItem } from '@/components/reui-kit/kpi-stat-grid'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
const ICON_CLASS: Record<KpiTone, string> = {
neutral: 'text-muted-foreground [&_svg]:text-current',
ok: 'text-success [&_svg]:text-current',
wn: 'text-warning [&_svg]:text-current',
er: 'text-destructive [&_svg]:text-current',
in: 'text-info [&_svg]:text-current',
}
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'warning-light' | 'destructive-light' | 'info-light'> = {
neutral: 'outline',
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
}
/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string },
): KpiStatItem {
const tone = opts.tone ?? 'neutral'
return {
id,
label,
icon: createElement(opts.icon),
iconClassName: ICON_CLASS[tone],
variant: tone === 'wn' ? 'warning' : tone === 'er' ? 'destructive' : 'default',
value: createElement(
'span',
{ className: 'flex flex-wrap items-baseline gap-x-2' },
value,
opts.sub ? createElement('span', { className: 'text-muted-foreground text-xs font-normal' }, opts.sub) : null,
),
footer: createElement(Badge, { variant: BADGE[tone], size: 'sm', className: 'whitespace-nowrap' }, opts.badge),
}
}
+47 -2
View File
@@ -21,7 +21,8 @@ import {
type AuditLogEntry, type AuditLogEntry,
} from '@authportal/shared' } from '@authportal/shared'
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in' import type { Tone } from '@/components/portal/tone'
export type { Tone }
export type NavItem = { export type NavItem = {
id: string id: string
@@ -151,7 +152,7 @@ export const MOCK_USERS: AdminUser[] = [
'ops@shnt.top', 'ops@shnt.top',
'Operations', 'Operations',
false, false,
['bgp', 'fw', 'mm'], ['bgp', 'fw', 'mm', 'dns'],
['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'], ['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'],
{ {
created_at: '2026-03-14T08:00:00.000Z', created_at: '2026-03-14T08:00:00.000Z',
@@ -455,3 +456,47 @@ export const USER_META: Record<string, { sub: string; activity: string }> = {
'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' }, 'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' },
'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' }, 'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' },
} }
/** Источник и свежесть данных по разделам — для панели «Источники и свежесть данных» в шапке. */
export const FRESH_BY_PAGE: Record<string, { badge: string; text: string }> = {
apps: { badge: 'Портал', text: 'Получено 14:02:11 · сессия действует до 22:02 · права из базы портала, не из токена' },
account: { badge: 'WebAuthn', text: 'Получено 14:02:11 · браузер поддерживает passkeys · домен auth.shnt.top' },
users: { badge: 'Портал', text: 'Получено 14:02:11 · GET /api/v1/admin/users · 7 учётных записей' },
logins: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=logins · хранение 90 дней' },
audit: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=changes · ingest от 5 приложений' },
oidc: { badge: 'OIDC', text: 'Получено 14:02:11 · discovery и JWKS доступны · подпись RS256' },
'app-links': { badge: 'Портал', text: 'Получено 14:02:11 · portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
}
export type AuditRow = {
id: string
action: string
summary: string
tone: Tone
label: string
source: string
who: string
ip: string
when: string
}
/** Таблица журнала с доски «Изменения» (значения те же, что на доске). */
export const AUDIT_ROWS: AuditRow[] = [
{ id: 'e1', action: 'user.update', summary: 'Отключён former@shnt.top', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '14:02' },
{ id: 'e2', action: 'user.access_update', summary: 'ops@shnt.top · +EvoFirewall:agents:write', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '13:50' },
{ id: 'e3', action: 'fw.policy_apply', summary: 'db-01 · поколение 128 · ошибка nft', tone: 'er', label: 'Critical', source: 'EvoFirewall', who: 'system', ip: '—', when: '12:31' },
{ id: 'e4', action: 'oidc.client_update', summary: 'Technitium DNS · новый секрет', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '09:12' },
{ id: 'e5', action: 'app_switcher.update', summary: '+CDN Manager в меню приложений', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: 'вчера 18:20' },
{ id: 'e6', action: 'mm.server_delete', summary: 'Удалён сервер old-edge', tone: 'wn', label: 'Warning', source: 'MikrotikManager', who: 'denis', ip: '198.51.100.4', when: 'вчера 15:02' },
{ id: 'e7', action: 'user.create', summary: 'Создан pavel@shnt.top', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '19.06' },
{ id: 'e8', action: 'audit.purge', summary: 'Очищено 212 записей старше 90 дн.', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '01.10' },
]
/** Лента с доски «Изменения». */
export const AUDIT_FEED: Array<{ t: string; title: string; text: string; label: string; tone: Tone }> = [
{ t: '14:02', title: 'Отключён пользователь former@shnt.top', text: 'admin · Auth Portal · 203.0.113.10', label: 'Warning', tone: 'wn' },
{ t: '13:50', title: 'Права обновлены: ops@shnt.top', text: '+EvoFirewall:agents:write · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: '12:31', title: 'Применена политика firewall (поколение 128)', text: 'system · EvoFirewall · db-01 — ошибка применения', label: 'Critical', tone: 'er' },
{ t: '09:12', title: 'Обновлён OIDC-клиент Technitium DNS', text: 'новый client_secret · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: 'вчера', title: 'Изменён список приложений в переключателе', text: '+CDN Manager · admin · Auth Portal', label: 'Info', tone: 'in' },
]
@@ -1,33 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
ChartColumnIcon,
CloudIcon,
EyeIcon,
FingerprintIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AppId, AppSwitcherIconName } from '@authportal/shared' import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field' import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { import {
@@ -36,16 +11,17 @@ import {
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue
} from '@authportal/ui/components/select' } from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { PageHead, PreviewSheet, StatusBadge } from '../bits' import { PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
import { kpi } from '../kpi' import { AppCards } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { MOCK_SWITCHER } from '../mock' import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store' 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 = { type LinkRow = {
id: AppId id: AppId
@@ -61,9 +37,12 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
server: { label: 'Сервер', Icon: ServerIcon }, server: { label: 'Сервер', Icon: ServerIcon },
globe: { label: 'Сеть', Icon: NetworkIcon }, globe: { label: 'Сеть', Icon: NetworkIcon },
dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon }, dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon },
chart: { label: 'График', Icon: ChartColumnIcon }, chart: { label: 'График', Icon: ChartColumnIcon }
} }
/** Названия иконок, как они подписаны на доске. */
const BOARD_ICON: Record<string, string> = { cfdm: 'cloud', vps: 'server', bgp: 'globe', fw: 'shield', dns: 'globe', cdn: 'cloud', mm: 'layout' }
const MODES = [ const MODES = [
{ value: 'JWT', label: 'JWT SSO (fragment)' }, { value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' }, { value: 'OIDC', label: 'OIDC' },
@@ -75,24 +54,12 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
url: a.url, url: a.url,
icon: a.icon, icon: a.icon,
mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC', mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC',
enabled: a.id !== 'mm', enabled: a.id !== 'mm'
})) }))
/** Строка настройки в Frame: слева название и описание, справа элемент управления (settings-10). */
function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
export function AppLinksPage() { export function AppLinksPage() {
const { addChange } = usePreviewStore() const { addChange, marksFor } = usePreviewStore()
const marks = marksFor('app-links')
const [rows, setRows] = useState<LinkRow[]>(INITIAL) const [rows, setRows] = useState<LinkRow[]>(INITIAL)
const rowsRef = useRef(rows) const rowsRef = useRef(rows)
useEffect(() => { useEffect(() => {
@@ -113,7 +80,9 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: enabled ? 'Показать в меню' : 'Скрыть из меню', title: enabled ? 'Показать в меню' : 'Скрыть из меню',
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`), lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
revert: () => setRows(before), resource: 'app-links',
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -134,7 +103,9 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: 'Порядок изменён', title: 'Порядок изменён',
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`], lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
revert: () => setRows(before), resource: 'app-links',
marks: { [item!.id]: 'dirty' },
revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -157,83 +128,21 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: 'Порядок сброшен', title: 'Порядок сброшен',
lines: ['− пользовательский порядок', '+ порядок каталога'], lines: ['− пользовательский порядок', '+ порядок каталога'],
revert: () => setRows(before), resource: 'app-links',
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
revert: () => setRows(before)
}) })
}, [addChange]) }, [addChange])
const columns = useMemo<GridColumn<LinkRow>[]>( const columns = useMemo<GridColumn<LinkRow>[]>(
() => [ () => [
{ { id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
id: 'order', { id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
title: '№', { id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{BOARD_ICON[r.id] ?? r.icon}</span> },
size: 52, { id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
cell: (r) => ( { id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
<span className="text-muted-foreground text-sm tabular-nums">
{rows.findIndex((x) => x.id === r.id) + 1}
</span>
),
},
{
id: 'name',
title: 'Приложение',
size: 280,
cell: (r) => {
const { Icon } = ICONS[r.icon]
return (
<div className="flex min-w-0 items-center gap-3">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
<Icon />
</IconTile>
<TwoLineCell title={r.name} sub={r.id} />
</div>
)
},
},
{
id: 'menu',
title: 'В меню',
size: 170,
cell: (r) => (
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
<Switch
size="sm"
checked={r.enabled}
onCheckedChange={(on) => setEnabled([r.id], on)}
aria-label={`${r.name}: показывать в меню`}
/>
<StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge>
</div>
),
},
{ id: 'url', title: 'URL', size: 260, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.url}</code> },
{ id: 'mode', title: 'Вход', size: 100, cell: (r) => <StatusBadge tone={r.mode === 'OIDC' ? 'wn' : 'in'}>{r.mode}</StatusBadge> },
{
id: 'act',
title: '',
size: 56,
cell: (r) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${r.name}`} />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={() => setEditing(r)}>
<PencilIcon aria-hidden />
Настроить
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => moveTo(r.id, () => 0)}>В начало</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i - 1)}>Выше</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i + 1)}>Ниже</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (_i, n) => n - 1)}>В конец</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
], ],
[rows, setEnabled, moveTo], [],
) )
const shown = rows.filter((r) => r.enabled) const shown = rows.filter((r) => r.enabled)
@@ -248,37 +157,35 @@ export function AppLinksPage() {
variant="outline" variant="outline"
onClick={() => onClick={() =>
toast('Добавление недоступно в макете', { toast('Добавление недоступно в макете', {
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.', description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.'
}) })
} }
> >
<PlusIcon aria-hidden />
Добавить приложение Добавить приложение
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }), kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }), kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }), kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }), kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: LinkIcon, badge: 'Technitium DNS' }),
]} ]}
/> />
<Tabs defaultValue="list" className="min-w-0"> <Tabs defaultValue="list" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="list">Приложения · {rows.length}</TabsTrigger> <PageTab value="list" label="Приложения" count={rows.length} />
<TabsTrigger value="menu">Меню</TabsTrigger> <PageTab value="menu" label="Меню" />
</TabsList> </PageTabsList>
<TabsContent value="list" className="min-w-0"> <TabsContent value="list" className="min-w-0">
<PreviewSectionGrid <SectionGrid
title="Ссылки сервисов" title="Ссылки сервисов"
description="Перетащите строку за ручку: порядок строк = порядок в меню · изменения применяются после сохранения" description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
rows={rows} rows={rows}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
selectable
pageSize={10} pageSize={10}
searchText={(r) => `${r.name} ${r.id} ${r.url}`} searchText={(r) => `${r.name} ${r.id} ${r.url}`}
searchPlaceholder="Название или адрес" searchPlaceholder="Название или адрес"
@@ -287,43 +194,32 @@ export function AppLinksPage() {
{ label: 'В меню', match: (r) => r.enabled }, { label: 'В меню', match: (r) => r.enabled },
{ label: 'Скрытые', match: (r) => !r.enabled }, { label: 'Скрытые', match: (r) => !r.enabled },
]} ]}
toolbar={ tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}> <Button type="button" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden />
Сбросить порядок Сбросить порядок
</Button> </Button>
} }
bulkActions={(ids, clear) => ( bulk={{
<> onLabel: 'Показать в меню',
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], true); clear() }}> offLabel: 'Скрыть',
Показать в меню onRun: (ids) => setEnabled(ids as AppId[], true),
</Button> offRun: (ids) => setEnabled(ids as AppId[], false)
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], false); clear() }}> }}
Скрыть rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
</Button> rowMenu={(r) => ({
</> onOpen: () => setEditing(r),
)} toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
onToggle: () => setEnabled([r.id], !r.enabled)
})}
onReorder={dropOn} onReorder={dropOn}
rowStatus={(r) => marks[r.id]}
onRowClick={setEditing} onRowClick={setEditing}
/> />
</TabsContent> </TabsContent>
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4"> <TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
<Frame spacing="sm" className="w-full"> <FormFrame title="Переключатель приложений" description="Как выглядит меню в шапке каждого приложения" badge="Опубликовано">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle>Переключатель приложений</FrameTitle>
<FrameDescription>Как выглядит меню в шапке каждого приложения</FrameDescription>
</div>
<StatusBadge tone="ok">Опубликовано</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Заголовок меню" description="Показывается над списком приложений"> <SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<Input <Input value={menuLabel} onChange={(e) => setMenuLabel(e.target.value)} aria-label="Заголовок меню" className="w-56 max-w-full" />
value={menuLabel}
onChange={(e) => setMenuLabel(e.target.value)}
aria-label="Заголовок меню"
className="w-56 max-w-full"
/>
</SettingRow> </SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню"> <SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" /> <Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
@@ -331,27 +227,27 @@ export function AppLinksPage() {
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке"> <SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" /> <Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow> </SettingRow>
</FramePanel> </FormFrame>
</Frame> <AppCards
<QuickActionGrid
title="Предпросмотр меню" title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`} description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
actions={shown.slice(0, 4).map((r) => { items={shown.slice(0, 4).map((r) => {
const { Icon } = ICONS[r.icon] const { Icon } = ICONS[r.icon]
return { return {
id: r.id, id: r.id,
title: r.name, title: r.name,
description: r.url.replace('https://', ''), desc: r.url.replace('https://', ''),
badgeLabel: r.mode, badge: r.mode,
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const), badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
icon: <Icon />, meta: r.id,
icon: <Icon />
} }
})} })}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={editing !== null} open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)} onOpenChange={(o) => !o && setEditing(null)}
title="Приложение" title="Приложение"
@@ -427,7 +323,7 @@ export function AppLinksPage() {
</Field> </Field>
</> </>
) : null} ) : null}
</PreviewSheet> </PortalSheet>
</> </>
) )
} }
+46 -40
View File
@@ -1,11 +1,13 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon } from 'lucide-react' import { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared' import { APPS, type AppId } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { PageHead } from '../bits' 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> = { const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon, cfdm: CloudIcon,
@@ -14,7 +16,7 @@ const ICONS: Record<AppId, typeof CloudIcon> = {
fw: ShieldIcon, fw: ShieldIcon,
dns: GlobeIcon, dns: GlobeIcon,
cdn: CloudIcon, cdn: CloudIcon,
mm: LayoutDashboardIcon, mm: LayoutDashboardIcon
} }
const NO_ACCESS: AppId[] = ['cdn'] const NO_ACCESS: AppId[] = ['cdn']
@@ -23,57 +25,61 @@ export function AppsPage() {
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id)) const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id)) const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
const toCard = (app: (typeof APPS)[number], open: boolean): AppCardItem => {
const Icon = ICONS[app.id]
const meta = `${app.url.replace('https://', '')} · ${app.authMode === '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
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
}
}
return ( return (
<> <>
<PageHead <PageHead
title="Приложения" title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={ actions={
<Button variant="outline" size="sm" render={<Link to="/design-preview/app-links" />} nativeButton={false}> <Button variant="outline" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
Ссылки приложений Ссылки приложений
</Button> </Button>
} }
/> />
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: BadgeCheckIcon, badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldIcon, badge: 'is_admin' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0"> <Tabs defaultValue="all" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger> <PageTab value="all" label="Доступные" count={allowed.length} />
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger> <PageTab value="no" label="Без доступа" count={denied.length} />
</TabsList> </PageTabsList>
<TabsContent value="all" className="flex flex-col gap-4"> <TabsContent value="all" className="min-w-0">
<QuickActionGrid <AppCards
title="Мои приложения" title="Мои приложения"
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами" description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
actions={allowed.map((app) => { items={allowed.map((a) => toCard(a, true))}
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
badgeVariant: 'info-light' as const,
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }),
}
})}
/> />
</TabsContent> </TabsContent>
<TabsContent value="no"> <TabsContent value="no" className="min-w-0">
<QuickActionGrid <AppCards
title="Недоступные приложения" title="Недоступные приложения"
description="Права не выданы · запросите у администратора" description="Права не выданы · запросите у администратора"
actions={denied.map((app) => { items={denied.map((a) => toCard(a, false))}
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Нет доступа',
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
})}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -1,48 +1,38 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared' import { KpiStrip } from '@/components/portal/kpi-strip'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Input } from '@authportal/ui/components/input'
import { CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, TypedConfirm } from '../bits' import { Switch } from '@authportal/ui/components/switch'
import { kpi } from '../kpi' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { MOCK_AUDIT, APP_SHORT, type Tone } from '../mock' import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
const SEVERITY_TONE: Record<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' } const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
const SEVERITY_LABEL: Record<AuditSeverity, string> = { info: 'Info', warning: 'Warning', critical: 'Critical' }
function sourceLabel(e: AuditLogEntry) { const columns: GridColumn<AuditRow>[] = [
return e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app] { 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) => mono(r.source) },
function whenLabel(iso: string) { { id: 'who', title: 'Кто', size: 120, cell: (r) => mono(r.who) },
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) { id: 'ip', title: 'IP', size: 140, cell: (r) => mono(r.ip) },
} { id: 'when', title: 'Когда', size: 140, cell: (r) => mono(r.when) },
const columns: GridColumn<AuditLogEntry>[] = [
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={SEVERITY_TONE[r.severity]} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
{ id: 'sev', title: 'Важность', size: 120, sortValue: (r) => r.severity, cell: (r) => <StatusBadge tone={SEVERITY_TONE[r.severity]}>{SEVERITY_LABEL[r.severity]}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source_app, cell: (r) => <span className="text-sm">{sourceLabel(r)}</span> },
{ id: 'who', title: 'Кто', size: 140, cell: (r) => <span className="text-sm">{r.actor_name ?? '—'}</span> },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => <span className="font-mono text-[13px]">{r.ip ?? '—'}</span> },
{ id: 'when', title: 'Когда', size: 140, sortValue: (r) => r.created_at, cell: (r) => <span className="text-sm whitespace-nowrap">{whenLabel(r.created_at)}</span> },
] ]
const VIEWS = [ const VIEWS = [
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Auth', match: (r: AuditLogEntry) => r.action.startsWith('auth.') }, { label: 'Warning и выше', match: (r: AuditRow) => r.tone === 'wn' || r.tone === 'er' },
{ label: 'Пользователи', match: (r: AuditLogEntry) => r.action.startsWith('user.') }, { label: 'Critical', match: (r: AuditRow) => r.tone === 'er' },
{ label: 'Настройки', match: (r: AuditLogEntry) => /^(oidc|app_switcher|audit)\./.test(r.action) },
{ label: 'Критичные', match: (r: AuditLogEntry) => r.severity === 'critical' },
] ]
export function AuditPage() { export function AuditPage() {
const [entry, setEntry] = useState<AuditLogEntry | null>(null) const [entry, setEntry] = useState<AuditRow | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false) const [purgeOpen, setPurgeOpen] = useState(false)
const [tab, setTab] = useState('feed')
const [retention, setRetention] = useState(90) const [retention, setRetention] = useState(90)
return ( return (
@@ -52,63 +42,78 @@ export function AuditPage() {
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest" sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={ actions={
<Button variant="outline" onClick={() => setPurgeOpen(true)}> <Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи… Очистить старые записи…
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }), kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }), kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }), kpi('crit', 'Критичных', '3', { icon: OctagonAlertIcon, tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }), kpi('src', 'Источников', '6', { icon: NetworkIcon, badge: 'портал +5 приложений' }),
]} ]}
/> />
<Tabs defaultValue="feed" className="min-w-0"> <Tabs value={tab} onValueChange={setTab} className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="feed">Лента</TabsTrigger> <PageTab value="feed" label="Лента" />
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger> <PageTab value="table" label="Таблица" count={148} />
<TabsTrigger value="retention">Ротация</TabsTrigger> <PageTab value="retention" label="Ротация" />
</TabsList> </PageTabsList>
<TabsContent value="feed" className="min-w-0"> <TabsContent value="feed" className="min-w-0">
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} /> <ActivityFeed
</TabsContent> title="Лента изменений"
<TabsContent value="table" className="min-w-0"> description="Последние события · иконка отражает тип, цвет — важность"
<PreviewSectionGrid items={AUDIT_FEED}
title="Журнал изменений" action={
description="Поиск по событию, пользователю и IP" <Button variant="outline" onClick={() => setTab('table')}>
rows={MOCK_AUDIT} Весь журнал
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`}
searchPlaceholder="Событие, пользователь, IP"
views={VIEWS}
onRowClick={setEntry}
toolbar={
<Button variant="outline" size="sm" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button> </Button>
} }
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention"> <TabsContent value="table" className="min-w-0">
<AuditRetentionPanel <SectionGrid
retentionDays={retention} title="Журнал изменений"
saving={false} description="Поиск по событию, пользователю и IP · виды по важности"
purging={false} rows={AUDIT_ROWS}
onSave={(days) => { columns={columns}
setRetention(days) getRowId={(r) => r.id}
toast.success(`Срок хранения: ${days} дн. (макет)`) searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
}} views={VIEWS}
onPurge={() => toast.success('Старые записи очищены (макет)')} footer="Показано 8 из 148"
onRowClick={setEntry}
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention">
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
<SettingRow title="Срок хранения" description="Записи старше будут удаляться автоматически">
<Input
key={retention}
defaultValue={`${retention} дней`}
aria-label="Срок хранения"
className="w-40 max-w-full font-mono"
onBlur={(e) => {
const n = Number.parseInt(e.target.value, 10)
if (Number.isFinite(n) && n >= 7 && n <= 3650 && n !== retention) {
setRetention(n)
toast.success(`Срок хранения: ${n} дн. (макет)`)
}
}}
/>
</SettingRow>
<SettingRow title="Автоматическая очистка" description="Раз в сутки в 03:00">
<Switch defaultChecked aria-label="Автоматическая очистка" />
</SettingRow>
<SettingRow title="Записей в журнале" description="Из них старше срока хранения — 212">
<StatusBadge tone="mu">1 482</StatusBadge>
</SettingRow>
</FormFrame>
</TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={entry !== null} open={entry !== null}
onOpenChange={(o) => !o && setEntry(null)} onOpenChange={(o) => !o && setEntry(null)}
title="Событие журнала" title="Событие журнала"
@@ -117,22 +122,22 @@ export function AuditPage() {
> >
{entry ? ( {entry ? (
<> <>
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
<div> <div>
<StatusBadge tone={SEVERITY_TONE[entry.severity]}>{SEVERITY_LABEL[entry.severity]}</StatusBadge> <StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
</div> </div>
<KvList <KvList
items={[ items={[
['Событие', `${entry.action} · ${entry.summary}`], ['Источник', entry.source],
['Источник', sourceLabel(entry)], ['Кто', `${entry.who}@shnt.top · ${entry.ip}`],
['Кто', `${entry.actor_email ?? 'system'}${entry.ip ? ` · ${entry.ip}` : ''}`],
]} ]}
/> />
<CodeBlock label="Подробности"> <CodeBlock label="Подробности">
{JSON.stringify({ target_type: entry.target_type, target_id: entry.target_id, ...entry.details }, null, 2)} {'target_type: user\ntarget_id: 7c1e…a3\nbefore: { disabled: false }\nafter: { disabled: true }'}
</CodeBlock> </CodeBlock>
</> </>
) : null} ) : null}
</PreviewSheet> </PortalSheet>
<TypedConfirm <TypedConfirm
open={purgeOpen} 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 { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Separator } from '@authportal/ui/components/separator' import { Separator } from '@authportal/ui/components/separator'
import { SignalAlert } from '../bits' import { SignalAlert } from '@/components/portal/bits'
const PERKS = [ const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' }, { icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
@@ -1,14 +1,15 @@
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge } from '../bits' import { ExplainBanner } from '@/components/portal/explain-banner'
import { kpi } from '../kpi' 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 { 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 д'] const PERIODS = ['24 ч', '7 д', '30 д', '90 д']
@@ -21,11 +22,11 @@ export function LoginsPage() {
const loginCols = useMemo<GridColumn<LoginRow>[]>( const loginCols = useMemo<GridColumn<LoginRow>[]>(
() => [ () => [
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> }, { 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: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'time', title: 'Время', size: 130, cell: (r) => <span className="text-sm whitespace-nowrap">{r.time}</span> }, { id: 'time', title: 'Время', size: 130, 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-[13px]">{r.ip}</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="text-sm">{r.app}</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="text-sm">{r.reason}</span> }, { id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
], ],
[], [],
) )
@@ -34,28 +35,9 @@ export function LoginsPage() {
() => [ () => [
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> }, { id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> },
{ id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="text-sm">{r.ipClient}</span> }, { id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> }, { id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="text-sm whitespace-nowrap">{r.expires}</span> }, { id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
{
id: 'act',
title: '',
size: 130,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}}
>
Завершить
</Button>
),
},
], ],
[], [],
) )
@@ -67,44 +49,40 @@ export function LoginsPage() {
sub="Журнал входов и активные сессии · портал и SSO в приложения" sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={ actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}> <Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV Экспорт CSV
</Button> </Button>
} }
/> />
<SignalAlert <ExplainBanner
tone="er" tone="er"
title="Подозрительная серия неудач: ops@shnt.top." title="Подозрительная серия неудач: ops@shnt.top."
action={ text="6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи»."
<Button variant="outline" size="sm" render={<Link to="/design-preview/users" />} nativeButton={false}>
К пользователям
</Button>
}
> >
6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи». 6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
</SignalAlert> </ExplainBanner>
<Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} /> <Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('ok', 'Успешные (24ч)', '38', { icon: LogInIcon, tone: 'ok', badge: 'входы и SSO', sub: 'без ошибок' }), kpi('ok', 'Успешные (24ч)', '38', { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }), kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }), kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }), kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: '3 пользователя' }),
]} ]}
/> />
<Tabs defaultValue="hist" className="min-w-0"> <Tabs defaultValue="hist" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="hist">История · 44</TabsTrigger> <PageTab value="hist" label="История" count={'44'} />
<TabsTrigger value="sess">Активные сессии · {sessions.length}</TabsTrigger> <PageTab value="sess" label="Активные сессии" count={sessions.length} />
</TabsList> </PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4"> <TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Журнал входов" title="Журнал входов"
description={`Поиск по логину и IP · период: ${PERIODS[period]}`} description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
rows={MOCK_LOGINS} rows={MOCK_LOGINS}
columns={loginCols} columns={loginCols}
getRowId={(r) => r.id} getRowId={(r) => r.id}
footer="Показано 8 из 44"
searchText={(r) => `${r.login} ${r.ip} ${r.app}`} searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
searchPlaceholder="Логин или IP" searchPlaceholder="Логин или IP"
views={[ views={[
@@ -116,18 +94,25 @@ export function LoginsPage() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4"> <TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Активные сессии" title="Активные сессии"
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений" description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
rows={sessions} rows={sessions}
columns={sessionCols} columns={sessionCols}
getRowId={(r) => r.id} getRowId={(r) => r.id}
emptyMessage="Нет активных сессий." emptyMessage="Нет активных сессий."
rowMenu={(r) => ({
toggleLabel: 'Завершить сессию',
onToggle: () => {
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}
})}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={event !== null} open={event !== null}
onOpenChange={(o) => !o && setEvent(null)} onOpenChange={(o) => !o && setEvent(null)}
title="Событие входа" title="Событие входа"
@@ -163,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> <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} ) : null}
</PreviewSheet> </PortalSheet>
</> </>
) )
} }
+50 -49
View File
@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CopyIcon, PlusIcon, RefreshCwIcon, AppWindowIcon, PowerIcon, TicketIcon, KeyRoundIcon } from 'lucide-react' import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { CreateOidcPortalSheet } from '../create-sheets'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import {
AlertDialog, AlertDialog,
@@ -10,24 +11,25 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle
} from '@authportal/ui/components/alert-dialog' } from '@authportal/ui/components/alert-dialog'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field' import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Textarea } from '@authportal/ui/components/textarea' import { Textarea } from '@authportal/ui/components/textarea'
import { CopyList, KvFrame, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' import { ExplainBanner } from '@/components/portal/explain-banner'
import { kpi } from '../kpi' import { CopyList, KvFrame, PageHead, PortalSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { import {
MOCK_CLIENT_SECRET, MOCK_CLIENT_SECRET,
MOCK_OIDC_CLIENTS, MOCK_OIDC_CLIENTS,
OIDC_CAPABILITIES, OIDC_CAPABILITIES,
OIDC_ENDPOINTS, OIDC_ENDPOINTS,
type OidcClientRow, type OidcClientRow,
type Tone, type Tone
} from '../mock' } 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 { function tone(c: OidcClientRow): Tone {
return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok' return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok'
@@ -47,29 +49,10 @@ export function OidcPage() {
() => [ () => [
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> }, { id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> }, { id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> },
{ id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[13px]">{r.clientId}</code> }, { id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[12.5px]">{r.clientId}</code> },
{ id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.redirect}</code> }, { id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[12.5px]">{r.redirect}</code> },
{ id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="text-sm">{r.secret}</span> }, { id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="font-mono text-[12.5px]">{r.secret}</span> },
{ id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.created}</span> }, { id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.created}</span> },
{
id: 'act',
title: '',
size: 170,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setRotating(r)
}}
>
<RefreshCwIcon aria-hidden />
Обновить секрет
</Button>
),
},
], ],
[], [],
) )
@@ -84,7 +67,7 @@ export function OidcPage() {
secret: 'действует', secret: 'действует',
created: '17.10.2026', created: '17.10.2026',
enabled: values.enabled, enabled: values.enabled,
stale: false, stale: false
} }
setClients((c) => [...c, created]) setClients((c) => [...c, created])
setCreateOpen(false) setCreateOpen(false)
@@ -100,32 +83,35 @@ export function OidcPage() {
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top" sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента Создать клиента
</Button> </Button>
} }
/> />
{staleCount > 0 ? ( {staleCount > 0 ? (
<SignalAlert tone="wn" title="Секрет клиента Technitium DNS старше 90 дней."> <ExplainBanner
tone="wn"
title="Секрет клиента Technitium DNS старше 90 дней."
text="Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления."
>
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления. Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
</SignalAlert> </ExplainBanner>
) : null} ) : null}
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }), kpi('n', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }), kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: CircleCheckIcon, tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }), kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }), kpi('key', 'Ключ подписи', 'RS256', { icon: LockKeyholeIcon, badge: 'ротация вручную' }),
]} ]}
/> />
<Tabs defaultValue="clients" className="min-w-0"> <Tabs defaultValue="clients" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="clients">Клиенты · {clients.length}</TabsTrigger> <PageTab value="clients" label="Клиенты" count={clients.length} />
<TabsTrigger value="meta">Параметры issuer</TabsTrigger> <PageTab value="meta" label="Параметры issuer" />
</TabsList> </PageTabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4"> <TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Зарегистрированные клиенты" title="Зарегистрированные клиенты"
description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении" description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении"
rows={clients} rows={clients}
@@ -138,6 +124,21 @@ export function OidcPage() {
{ label: 'Включённые', match: (r) => r.enabled }, { label: 'Включённые', match: (r) => r.enabled },
{ label: 'Выключенные', match: (r) => !r.enabled }, { label: 'Выключенные', match: (r) => !r.enabled },
]} ]}
tools={
<Button
type="button"
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
Обновить секрет…
</Button>
}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: on } : x))) }}
rowMenu={(r) => ({
onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Выключить' : 'Включить',
onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))
})}
onRowClick={setEditing} onRowClick={setEditing}
/> />
</TabsContent> </TabsContent>
@@ -147,9 +148,9 @@ export function OidcPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} /> <CreateOidcPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet <PortalSheet
open={editing !== null} open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)} onOpenChange={(o) => !o && setEditing(null)}
title="OIDC-клиент" title="OIDC-клиент"
@@ -192,7 +193,7 @@ export function OidcPage() {
</Field> </Field>
</> </>
) : null} ) : null}
</PreviewSheet> </PortalSheet>
<TypedConfirm <TypedConfirm
open={rotating !== null} open={rotating !== null}
@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@authportal/ui/components/field' import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' import { ExplainBanner } from '@/components/portal/explain-banner'
import { kpi } from '../kpi' 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 { 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() { export function SecurityPage() {
const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS) const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS)
@@ -21,13 +21,19 @@ export function SecurityPage() {
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<PasskeyRow | null>(null) const [deleting, setDeleting] = useState<PasskeyRow | null>(null)
const [revoked, setRevoked] = useState<string[]>([])
const view = useMemo(
() => keys.map((k) => (revoked.includes(k.id) ? { ...k, tone: 'mu' as const, status: 'Отозван' } : k)),
[keys, revoked],
)
const columns = useMemo<GridColumn<PasskeyRow>[]>( const columns = useMemo<GridColumn<PasskeyRow>[]>(
() => [ () => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> }, { id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> },
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="text-sm">{r.kind}</span> }, { id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.added}</span> }, { id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.kind}</span> },
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="text-sm">{r.lastUsed}</span> }, { id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.added}</span> },
{ id: 'status', title: 'Статус', size: 200, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.lastUsed}</span> },
], ],
[], [],
) )
@@ -39,42 +45,60 @@ export function SecurityPage() {
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом" sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={ actions={
<Button onClick={() => setAddOpen(true)}> <Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey Добавить passkey
</Button> </Button>
} }
/> />
{keys.length === 1 ? ( <ExplainBanner
<SignalAlert tone="wn" title="Один passkey — единственная копия."> tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</SignalAlert> </ExplainBanner>
) : null} <KpiStrip
<KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: keys.length > 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }), kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }), kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }), kpi('pw', 'Пароль', 'Задан', { icon: LockIcon, badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }), kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства' }),
]} ]}
/> />
<Tabs defaultValue="keys" className="min-w-0"> <Tabs defaultValue="keys" className="min-w-0">
<TabsList className="max-w-full justify-start overflow-x-auto"> <PageTabsList>
<TabsTrigger value="keys">Passkeys · {keys.length}</TabsTrigger> <PageTab value="keys" label="Passkeys" count={keys.length} />
<TabsTrigger value="pass">Пароль</TabsTrigger> <PageTab value="pass" label="Пароль" />
<TabsTrigger value="acc">Учётная запись</TabsTrigger> <PageTab value="acc" label="Учётная запись" />
</TabsList> </PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4"> <TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <SectionGrid
title="Мои passkeys" title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо" description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={keys} rows={view}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' }, { label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]} ]}
rowSwitch={{
value: (r) => r.status !== 'Отозван',
onChange: (r, on) => {
setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id]))
toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)')
}
}}
rowMenu={(r) => ({
onOpen: () => {
setSelected(r)
setRenameValue(r.name)
},
toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать',
onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])),
onDelete: () => setDeleting(r)
})}
onRowClick={(r) => { onRowClick={(r) => {
setSelected(r) setSelected(r)
setRenameValue(r.name) setRenameValue(r.name)
@@ -82,41 +106,20 @@ export function SecurityPage() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="pass"> <TabsContent value="pass">
<Frame spacing="sm" className="w-full"> <FormFrame title="Пароль" description="Запасной вход, если passkey недоступен" badge="Задан">
<FrameHeader className="flex-row items-center justify-between gap-3"> <SettingRow title="Последняя смена" description="Рекомендуем менять раз в год">
<div className="flex flex-col gap-0.5"> <StatusBadge tone="mu">11.03.2026</StatusBadge>
<FrameTitle>Пароль</FrameTitle> </SettingRow>
<FrameDescription>Запасной вход, если passkey недоступен</FrameDescription> <SettingRow title="Текущий пароль" description="Для подтверждения смены">
</div> <Input type="password" defaultValue="••••••••••" autoComplete="current-password" aria-label="Текущий пароль" className="w-56 max-w-full font-mono" />
<StatusBadge tone="ok">Задан</StatusBadge> </SettingRow>
</FrameHeader> <SettingRow title="Новый пароль" description="Не короче 12 символов">
<FramePanel> <Input type="password" autoComplete="new-password" aria-label="Новый пароль" className="w-56 max-w-full font-mono" />
<FieldGroup className="max-w-xl"> </SettingRow>
<Field> <SettingRow title="Завершить остальные сессии" description="После смены пароля выйти на всех других устройствах">
<FieldLabel htmlFor="pw-cur">Текущий пароль</FieldLabel> <Switch defaultChecked aria-label="Завершить остальные сессии" />
<Input id="pw-cur" type="password" defaultValue="••••••••••" autoComplete="current-password" /> </SettingRow>
<FieldDescription>Для подтверждения смены · последняя смена 11.03.2026</FieldDescription> </FormFrame>
</Field>
<Field>
<FieldLabel htmlFor="pw-new">Новый пароль</FieldLabel>
<Input id="pw-new" type="password" autoComplete="new-password" />
<FieldDescription>Не короче 12 символов</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="pw-out" defaultChecked />
<div className="flex flex-col">
<FieldLabel htmlFor="pw-out">Завершить остальные сессии</FieldLabel>
<FieldDescription>После смены пароля выйти на всех других устройствах</FieldDescription>
</div>
</Field>
<div>
<Button variant="outline" onClick={() => toast.success('Пароль изменён (макет)')}>
Сменить пароль
</Button>
</div>
</FieldGroup>
</FramePanel>
</Frame>
</TabsContent> </TabsContent>
<TabsContent value="acc"> <TabsContent value="acc">
<KvFrame <KvFrame
@@ -134,7 +137,7 @@ export function SecurityPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<PreviewSheet <PortalSheet
open={addOpen} open={addOpen}
onOpenChange={setAddOpen} onOpenChange={setAddOpen}
title="Добавить passkey" title="Добавить passkey"
@@ -166,9 +169,9 @@ export function SecurityPage() {
<SignalAlert tone="in" title="Что произойдёт дальше"> <SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство. Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert> </SignalAlert>
</PreviewSheet> </PortalSheet>
<PreviewSheet <PortalSheet
open={selected !== null} open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)} onOpenChange={(o) => !o && setSelected(null)}
title="Passkey" title="Passkey"
@@ -210,7 +213,7 @@ export function SecurityPage() {
['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`], ['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`],
]} ]}
/> />
</PreviewSheet> </PortalSheet>
<TypedConfirm <TypedConfirm
open={deleting !== null} open={deleting !== null}
+77 -115
View File
@@ -1,52 +1,35 @@
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react' import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { AdminUser, CreateUserRequest } from '@authportal/shared' import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' import { CreateUserPortalSheet } from '../create-sheets'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react' import { AccessSheet } from '../access-sheet'
import { AccessDialog } from '../access-dialog'
import { diffAccess, type AccessValue } from '../access-model' import { diffAccess, type AccessValue } from '../access-model'
import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits' import { PageHead, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '../kpi' import { kpi } from '@/components/portal/kpi'
import { ALL_APP_IDS, APP_SHORT, MOCK_USERS, USER_JOURNAL, type Tone } from '../mock' import { ALL_APP_IDS } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { ExplainBanner } from '@/components/portal/explain-banner'
import { StatusBadge } from '../bits' import { AccessMatrix } from '../access-matrix'
import { UsersGrid, type UsersGridHandlers } from '../users-grid' import { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store' import { usePreviewStore } from '../preview-store'
import { usePatchUsers } from '../user-actions'
export function UsersPage() { export function UsersPage() {
const { addChange } = usePreviewStore() const { addChange, marksFor, users, setUsers } = usePreviewStore()
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS) const marks = marksFor('users')
const usersRef = useRef(users) const usersRef = useRef(users)
useEffect(() => { useEffect(() => {
usersRef.current = users usersRef.current = users
}, [users]) }, [users])
const patch = usePatchUsers()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null) const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null) const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [resetFor, setResetFor] = useState<AdminUser | null>(null) const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [bulkResetIds, setBulkResetIds] = useState<string[] | null>(null)
/** Меняет поля у выбранных пользователей и кладёт изменение в «Не сохранено». */
const patch = useCallback(
(ids: string[], apply: (u: AdminUser) => AdminUser, title: string, lineFor: (before: AdminUser, after: AdminUser) => string[]) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange],
)
const onDeactivate = useCallback( const onDeactivate = useCallback(
(user: AdminUser) => (user: AdminUser) =>
@@ -71,46 +54,39 @@ export function UsersPage() {
last_login_ip: null, last_login_ip: null,
passkey_count: 0, passkey_count: 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString()
} }
setUsers((prev) => [...prev, created]) setUsers((prev) => [...prev, created])
addChange({ addChange({
kind: 'add', kind: 'add',
title: `Создан ${created.email}`, title: `Создан ${created.email}`,
lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`], lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`],
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)), resource: 'users',
marks: { [created.id]: 'new' },
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id))
}) })
setCreateOpen(false) setCreateOpen(false)
setAccessFor(created) setAccessFor(created)
}, },
[addChange], [addChange, setUsers],
) )
const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), []) const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), [])
const onResetPasskeys = useCallback((user: AdminUser) => setResetFor(user), [])
const onOpenAudit = useCallback((user: AdminUser) => setJournalFor(user), [])
const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), []) const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), [])
const handlers: UsersGridHandlers = { const handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess, onOpenAccess: onOpenAccess,
onOpenJournal: onOpenAudit,
onToggleDisabled: onDeactivate, onToggleDisabled: onDeactivate,
onResetPasskeys,
onDelete, onDelete,
onBulkDelete: (ids) => setBulkDeleteIds(ids),
onBulkResetPasskeys: (ids) => setBulkResetIds(ids),
onBulkDisabled: (ids, disabled) => onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [ patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− disabled: ${String(!disabled)}`, `− disabled: ${String(!disabled)}`,
`+ disabled: ${String(disabled)}`, `+ disabled: ${String(disabled)}`,
]), ])
} }
const matrix: MatrixRow[] = ALL_APP_IDS.map((id) => {
const mine = users.filter((u) => u.apps.includes(id))
const level = (action: string) => mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(`:${action}`))).length
const admins = mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(':admin'))).length
return { id, name: APP_SHORT[id], total: mine.length, read: level('read'), write: level('write'), admin: admins, tone: admins < 2 ? 'wn' : 'ok' }
})
const admins = users.filter((u) => u.is_admin).length const admins = users.filter((u) => u.is_admin).length
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
const disabled = users.filter((u) => u.disabled).length const disabled = users.filter((u) => u.disabled).length
@@ -122,43 +98,43 @@ export function UsersPage() {
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал" sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя Создать пользователя
</Button> </Button>
} }
/> />
<KpiStatGrid <ExplainBanner
tone="er"
title="Подозрительная активность: ops@shnt.top."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner>
<KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }), kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }), kpi('admins', 'Администраторов', String(admins), { icon: ShieldIcon, tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }), kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }), kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт' }),
]} ]}
/> />
<Tabs defaultValue="users" className="min-w-0"> <Tabs defaultValue="users" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="users">Участники · {users.length}</TabsTrigger> <PageTab value="users" label="Участники" count={users.length} />
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger> <PageTab value="matrix" label="Доступ по приложениям" count={ALL_APP_IDS.length} />
</TabsList> </PageTabsList>
<TabsContent value="users" className="min-w-0"> <TabsContent value="users" className="min-w-0">
<UsersGrid users={users} handlers={handlers} /> <UsersGrid users={users} handlers={handlers} marks={marks} />
</TabsContent> </TabsContent>
<TabsContent value="matrix" className="min-w-0"> <TabsContent value="matrix" className="min-w-0">
<PreviewSectionGrid <AccessMatrix />
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={matrix}
columns={matrixColumns}
getRowId={(r) => r.id}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} /> <CreateUserPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? ( {accessFor ? (
<AccessDialog <AccessSheet
key={accessFor.id} key={accessFor.id}
user={accessFor} user={accessFor}
onClose={() => setAccessFor(null)} onClose={() => setAccessFor(null)}
@@ -174,22 +150,6 @@ export function UsersPage() {
/> />
) : null} ) : null}
<PreviewSheet
open={journalFor !== null}
onOpenChange={(o) => !o && setJournalFor(null)}
title="Журнал пользователя"
description="Последние события пользователя из журнала портала"
footer={<Button variant="outline" onClick={() => setJournalFor(null)}>Закрыть</Button>}
>
<KvList
items={[
['Email', journalFor?.email ?? ''],
['Последний вход', `${journalFor?.last_login_ip ?? '—'}`],
['Passkeys', String(journalFor?.passkey_count ?? 0)],
]}
/>
<CodeBlock label={`${journalFor?.email ?? ''} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</PreviewSheet>
<TypedConfirm <TypedConfirm
open={deleteFor !== null} open={deleteFor !== null}
@@ -201,52 +161,54 @@ export function UsersPage() {
onConfirm={() => { onConfirm={() => {
const target = deleteFor const target = deleteFor
if (!target) return if (!target) return
const index = usersRef.current.findIndex((u) => u.id === target.id)
setUsers((prev) => prev.filter((u) => u.id !== target.id))
addChange({ addChange({
kind: 'del', kind: 'del',
title: `Удалён ${target.email}`, title: `Удалён ${target.email}`,
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`], lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
revert: () => resource: 'users',
setUsers((prev) => { marks: { [target.id]: 'deleted' },
const copy = [...prev] commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id))
copy.splice(Math.min(index, copy.length), 0, target)
return copy
}),
}) })
setDeleteFor(null) setDeleteFor(null)
}} }}
/> />
<TypedConfirm <TypedConfirm
open={resetFor !== null} open={bulkDeleteIds !== null}
onOpenChange={(o) => !o && setResetFor(null)} onOpenChange={(o) => !o && setBulkDeleteIds(null)}
title="Сбросить passkeys пользователя?" title="Удалить выбранных пользователей?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`} description={`Будут удалены учётные записи: ${bulkDeleteIds?.length ?? 0}. Действие попадёт в панель «Не сохранено» и в журнал.`}
expected="удалить"
confirmLabel="Удалить"
onConfirm={() => {
const ids = bulkDeleteIds ?? []
const targets = usersRef.current.filter((u) => ids.includes(u.id))
addChange({
kind: 'del',
title: `Удалено пользователей: ${targets.length}`,
lines: targets.map((t) => `− ${t.email}`),
resource: 'users',
marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])),
commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id)))
})
setBulkDeleteIds(null)
}}
/>
<TypedConfirm
open={bulkResetIds !== null}
onOpenChange={(o) => !o && setBulkResetIds(null)}
title="Сбросить passkeys выбранных пользователей?"
description={`Все ключи у выбранных учётных записей (${bulkResetIds?.length ?? 0}) будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
expected="сбросить" expected="сбросить"
confirmLabel="Сбросить" confirmLabel="Сбросить"
onConfirm={() => { onConfirm={() => {
const target = resetFor const ids = bulkResetIds ?? []
if (!target) return patch(ids, (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${ids.length}`, (b) => [`− passkeys: ${b.passkey_count}`, '+ passkeys: 0'])
patch([target.id], (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${target.email}`, (b) => [ setBulkResetIds(null)
`− passkeys: ${b.passkey_count}`,
'+ passkeys: 0',
])
toast.warning('Passkeys сброшены в черновике', { description: 'Сохраните изменения, чтобы применить.' })
setResetFor(null)
}} }}
/> />
</> </>
) )
} }
type MatrixRow = { id: string; name: string; total: number; read: number; write: number; admin: number; tone: Tone }
const matrixColumns: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 240, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.id} /> },
{ id: 'state', title: 'Статус', size: 160, cell: (r) => <StatusBadge tone={r.tone}>{r.tone === 'wn' ? 'Мало админов' : 'Выдан'}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, sortValue: (r) => r.total, cell: (r) => <span className="text-sm tabular-nums">{r.total}</span> },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.read}</span> },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.write}</span> },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.admin}</span> },
]
+251 -228
View File
@@ -1,33 +1,31 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState, type ComponentType } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router' import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { import {
ActivityIcon, ActivityIcon,
ArrowRightIcon,
CheckIcon, CheckIcon,
ChevronDownIcon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
CloudIcon, CloudIcon,
ContrastIcon,
FingerprintIcon, FingerprintIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
KeyRoundIcon, KeyRoundIcon,
ShieldCheckIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LogOutIcon, LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
RowsIcon,
SearchIcon, SearchIcon,
ServerIcon, ServerIcon,
StarIcon, StarIcon,
ShieldIcon, ShieldIcon,
SunIcon, MoonIcon,
UserPlusIcon, PlayIcon,
PlusIcon,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared' import { APPS, type AppId } from '@authportal/shared'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -47,11 +45,9 @@ import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu' } from '@authportal/ui/components/dropdown-menu'
import { Kbd } from '@authportal/ui/components/kbd' import { Kbd } from '@authportal/ui/components/kbd'
import { Switch } from '@authportal/ui/components/switch'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover' import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { Separator } from '@authportal/ui/components/separator' import { Separator } from '@authportal/ui/components/separator'
import { import {
@@ -71,7 +67,8 @@ import {
useSidebar, useSidebar,
} from '@authportal/ui/components/sidebar' } from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils' import { cn } from '@authportal/ui/lib/utils'
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock' 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' import { usePreviewStore } from './preview-store'
const APP_ICON: Record<AppId, typeof CloudIcon> = { const APP_ICON: Record<AppId, typeof CloudIcon> = {
@@ -94,11 +91,11 @@ function PreviewAppSwitcher() {
render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />} render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />}
> >
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden> <div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden>
<KeyRoundIcon className="size-4" /> <ShieldCheckIcon className="size-4" />
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Auth Portal</span> <span className="truncate font-semibold">Auth Portal</span>
<span className="text-muted-foreground truncate text-xs">auth.shnt.top</span> <span className="text-muted-foreground truncate text-xs">Единый вход · SSO</span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto size-4" /> <ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -136,163 +133,90 @@ function PreviewAppSwitcher() {
) )
} }
const THEMES = [ const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
{ value: 'light', label: 'Светлая', icon: SunIcon },
{ value: 'dark', label: 'Тёмная', icon: MoonIcon },
{ value: 'system', label: 'Системная', icon: MonitorIcon },
] as const
function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: ReadonlyArray<{ value: T; label: string; icon?: typeof SunIcon }>
onChange: (v: T) => void
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{options.map((o) => {
const active = value === o.value
const Icon = o.icon
return (
<Button
key={o.value}
type="button"
role="radio"
aria-checked={active}
aria-label={o.label}
title={o.label}
variant="ghost"
size={Icon ? 'icon-xs' : 'xs'}
onClick={() => onChange(o.value)}
className={cn(
'rounded-full',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{Icon ? <Icon className="size-3.5" aria-hidden /> : o.label}
</Button>
)
})}
</div>
)
}
function PreviewNavUser() { function PreviewNavUser() {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { theme, setTheme } = useTheme() const { theme, setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const { density, setDensity, contrast, setContrast } = usePreviewStore() const { density, setDensity, contrast, setContrast } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <Popover>
<DropdownMenuTrigger <PopoverTrigger
render={ render={
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
aria-label="Профиль: Admin, admin@shnt.top" aria-label="Профиль и настройки интерфейса: Admin, admin@shnt.top"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground" className="bg-muted/40 aria-expanded:bg-muted"
/> />
} }
> >
<Avatar className="size-8 rounded-lg"> <div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback> AD
</Avatar> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate font-semibold">Admin</span> <span className="truncate text-sm font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span> <span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto size-4" /> <ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
className="min-w-64 rounded-lg" <div className="flex items-center gap-2.5">
side={isMobile ? 'bottom' : 'right'} <div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
align="end" AD
sideOffset={4} </div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">Admin</span>
<span className="text-muted-foreground text-xs">admin@shnt.top</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: '/design-preview/account' })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
> >
<DropdownMenuGroup> <FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
<DropdownMenuLabel className="text-foreground flex items-center gap-2 py-2 font-normal">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/account' })}>
<FingerprintIcon aria-hidden />
Безопасность Безопасность
</DropdownMenuItem> </button>
<DropdownMenuItem className="cursor-default focus:bg-transparent"> <div className="flex flex-col gap-1.5">
<PaletteIcon aria-hidden /> <span className={LABEL_CLASS}>Тема</span>
Тема
<div className="ml-auto">
<Segmented <Segmented
label="Тема" label="Тема"
value={(mounted ? theme : 'system') ?? 'system'} options={['Тёмная', 'Светлая']}
options={THEMES} value={current === 'light' ? 1 : 0}
onChange={setTheme} onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/> />
</div> <span className={LABEL_CLASS}>Плотность</span>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<RowsIcon aria-hidden />
Плотность
<div className="ml-auto">
<Segmented <Segmented
label="Плотность" label="Плотность"
value={density} options={['Обычная', 'Компактная']}
options={[ value={density === 'compact' ? 1 : 0}
{ value: 'comfortable', label: 'Обычная' }, onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
{ value: 'compact', label: 'Компактная' },
]}
onChange={setDensity}
/> />
</div> <span className={LABEL_CLASS}>Контраст</span>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<ContrastIcon aria-hidden />
Контраст
<div className="ml-auto">
<Segmented <Segmented
label="Контраст" label="Контраст"
value={contrast} options={['Стандартный', 'Высокий']}
options={[ value={contrast === 'high' ? 1 : 0}
{ value: 'standard', label: 'Обычный' }, onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
{ value: 'high', label: 'Высокий' },
]}
onChange={setContrast}
/> />
</div> </div>
</DropdownMenuItem> <Separator />
</DropdownMenuGroup> <button
<DropdownMenuSeparator /> type="button"
<DropdownMenuGroup> onClick={() => void navigate({ to: '/design-preview/login' })}
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/login' })}> className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
<LogOutIcon aria-hidden /> >
<LogOutIcon className="size-4" aria-hidden />
Выйти Выйти
</DropdownMenuItem> </button>
</DropdownMenuGroup> </PopoverContent>
</DropdownMenuContent> </Popover>
</DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) )
@@ -308,19 +232,21 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
<SidebarMenuButton <SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label} tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active} isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />} render={<Link to={item.to} />}
> >
<span className="relative flex shrink-0">
<Icon className="size-4" /> <Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? ( {item.problem ? (
<span <span
role="img" role="img"
aria-label={item.problem.label} aria-label={item.problem.label}
className={cn('ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2', DOT_CLASS[item.problem.tone])} 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} ) : null}
</span>
<span>{item.label}</span>
</SidebarMenuButton> </SidebarMenuButton>
<SidebarMenuAction <SidebarMenuAction
showOnHover={!pinned} showOnHover={!pinned}
@@ -337,16 +263,17 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
export function PreviewSidebar() { export function PreviewSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = usePreviewStore() const { pins, togglePin } = usePreviewStore()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id)) const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader className="pb-1.5">
<PreviewAppSwitcher /> <PreviewAppSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{pinned.length > 0 ? ( {pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel> <SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{pinned.map((item) => ( {pinned.map((item) => (
@@ -358,11 +285,23 @@ export function PreviewSidebar() {
) : null} ) : null}
{NAV_GROUPS.map((group, gi) => ( {NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null} {gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup> <SidebarGroup className="py-0">
<SidebarGroupLabel>{group}</SidebarGroupLabel> <SidebarGroupLabel
<SidebarGroupContent> render={
<SidebarMenu> <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">
{NAV_ITEMS.filter((i) => i.group === group).map((item) => ( {NAV_ITEMS.filter((i) => i.group === group).map((item) => (
<NavEntry <NavEntry
key={item.id} key={item.id}
@@ -385,86 +324,114 @@ export function PreviewSidebar() {
) )
} }
const MONITOR_METRICS = [
{ l: 'Пользователи без passkey', v: '3 из 7', p: 57, tone: 'warning' },
{ l: 'Приложения доступны', v: '7 из 7', p: 100, tone: 'success' },
{ l: 'OIDC-клиенты', v: '1 из 2', p: 50, tone: 'warning' },
{ l: 'Сессии', v: '5', p: 40, tone: 'success' },
] as const
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() { function MonitorPopover() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok') const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<button <Button
variant="outline"
type="button" type="button"
aria-label={`Монитор Auth Portal: внимание, ${errors} ошибка, ${warnings} предупреждения`} aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 text-warning hover:bg-warning/15 focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-lg border px-3 text-sm font-medium outline-none focus-visible:ring-2" 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 aria-hidden className="size-4" /> <ActivityIcon className="text-warning size-4" aria-hidden />
<span className="hidden @md/header:inline">Внимание</span> <span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="bg-destructive inline-flex size-5 items-center justify-center rounded-full text-xs font-semibold text-white tabular-nums"> <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} {bad.length}
</span> </span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-0! p-0!"> <PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5"> <div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex flex-col"> <div className="grid leading-tight">
<span className="text-xs font-medium">Монитор Auth Portal</span> <b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-[11px]">обновлено 14:02</span> <span className="text-muted-foreground text-xs">обновлено 14:02</span>
</div> </div>
<Badge variant="warning-light" size="sm">Внимание</Badge> <Badge variant="warning-light" size="sm">Внимание</Badge>
</div> </div>
<div className="text-muted-foreground flex gap-3 border-b px-3 py-2 text-xs"> <div className="grid grid-cols-3 gap-1.5">
<span><b className="text-destructive font-semibold">{errors}</b> ошибка</span> {[
<span><b className="text-warning font-semibold">{warnings}</b> предупреждения</span> { n: errors, l: 'сбой', c: 'text-destructive' },
<span><b className="text-success font-semibold">{MONITOR_ITEMS.length - bad.length}</b> в норме</span> { n: warnings, l: 'предупреждения', c: 'text-warning' },
</div> { n: ok, l: 'в норме', c: 'text-success' },
<ul className="flex flex-col"> ].map((x) => (
{MONITOR_ITEMS.map((m) => ( <span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<li key={m.id} className="border-border border-b last:border-b-0"> <b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
<Link to={m.to} className="hover:bg-accent flex items-start gap-2.5 px-3 py-2.5 outline-none focus-visible:bg-accent"> {x.l}
<span
aria-hidden
className={cn(
'mt-1.5 size-2 shrink-0 rounded-full',
m.tone === 'er' ? 'bg-destructive' : m.tone === 'wn' ? 'bg-warning' : 'bg-success',
)}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium">{m.title}</span>
<span className="text-muted-foreground text-xs">{m.text}</span>
</span> </span>
<span className="text-muted-foreground text-[11px] whitespace-nowrap">{m.age}</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> </Link>
</li> </li>
))} ))}
</ul> </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="/design-preview/logins" className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
Журнал входов
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} }
function FreshnessPopover() { function FreshnessPopover() {
const [auto, setAuto] = useState(true) const pathname = useRouterState({ select: (st) => st.location.pathname })
const key = pathname.split('/')[2] ?? 'apps'
const fresh = FRESH_BY_PAGE[key] ?? FRESH_BY_PAGE.apps!
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" /> <Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
} }
> >
<InfoIcon aria-hidden /> <InfoIcon aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-72 max-w-[calc(100vw-2rem)] flex-col gap-3"> <PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<div className="flex items-center gap-2"> <Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<span className="bg-success size-2 rounded-full" aria-hidden /> <p className="text-muted-foreground text-sm">{fresh.text}</p>
<span className="text-sm font-medium">Обновлено 14:02:11 · Live</span>
</div>
<p className="text-muted-foreground text-xs">Данные портала: GET /api/v1/admin/* · сессия и права из базы портала. В макете запросы не отправляются.</p>
<label className="flex items-center justify-between gap-2 text-sm">
Автообновление
<Switch checked={auto} onCheckedChange={setAuto} aria-label="Автообновление" />
</label>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
@@ -476,7 +443,7 @@ export function PreviewHeader() {
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return ( return (
<header className="bg-background @container/header sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6"> <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" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0"> <Breadcrumb className="min-w-0">
@@ -491,7 +458,7 @@ export function PreviewHeader() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon-sm" size="icon"
aria-label="Командная палитра (⌘K)" aria-label="Командная палитра (⌘K)"
className="md:hidden" className="md:hidden"
onClick={() => setPaletteOpen(true)} onClick={() => setPaletteOpen(true)}
@@ -509,31 +476,85 @@ type PaletteEntry = {
group: string group: string
label: string label: string
hint: string hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void run: () => void
} }
const PALETTE_USERS = [
{ name: 'admin@shnt.top', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: 'ops@shnt.top', role: 'Пользователь · 4 приложения', dot: 'er' },
{ name: 'denis@shnt.top', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: 'dns-bot@shnt.top', role: 'Пользователь · Technitium DNS', dot: 'ok' },
{ name: 'former@shnt.top', role: 'Отключён', dot: 'mu' },
] as const
const PALETTE_APPS = [
{ name: 'Cloudflare Domain Manager', role: 'SSO · JWT' },
{ name: 'VPS Tracker', role: 'SSO · JWT' },
{ name: 'EvoBGP', role: 'SSO · JWT' },
{ name: 'EvoFirewall', role: 'SSO · JWT' },
{ name: 'Technitium DNS', role: 'OIDC' },
]
const PALETTE_OIDC = [
{ name: 'Technitium DNS', role: 'client_id dns-shnt · включён' },
{ name: 'Grafana (тест)', role: 'client_id grafana-test · выключен' },
]
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() { export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = usePreviewStore() const { paletteOpen, setPaletteOpen } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [active, setActive] = useState(0) const [active, setActive] = useState(0)
const entries = useMemo<PaletteEntry[]>(() => {
const go = (to: string) => () => void navigate({ to })
return [
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
{ id: 'act-user', group: 'Действия', label: 'Создать пользователя', hint: 'Пользователи', run: go('/design-preview/users') },
{ id: 'act-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') },
{ id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') },
]
}, [navigate])
const shown = useMemo(() => { const shown = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
return q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
}, [entries, query]) const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, run: go('/design-preview/users') },
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, run: go('/design-preview/oidc') },
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, run: go('/design-preview/account') },
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: PlayIcon, run: go('/design-preview/logins') },
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...NAV_ITEMS.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) })),
...PALETTE_USERS.filter((u) => match(u.name, u.role))
.slice(0, 5)
.map((u) => ({ id: `user-${u.name}`, group: 'Пользователи', label: u.name, hint: u.role, dot: u.dot, run: go('/design-preview/users') })),
...PALETTE_APPS.filter((a) => match(a.name))
.slice(0, 4)
.map((a) => ({ id: `app-${a.name}`, group: 'Приложения', label: a.name, hint: a.role, dot: 'mu' as const, run: go('/design-preview/apps') })),
...PALETTE_OIDC.filter((c) => match(c.name))
.slice(0, 4)
.map((c) => ({ id: `oidc-${c.name}`, group: 'OIDC-клиенты', label: c.name, hint: c.role, dot: 'mu' as const, run: go('/design-preview/oidc') })),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Найдено 6 событий у 2 пользователей. Это черновик — результат нужно проверить.',
run: () => void navigate({ to: '/design-preview/logins' }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme])
const groups = useMemo(() => { const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>() const map = new Map<string, Array<PaletteEntry & { index: number }>>()
@@ -553,17 +574,17 @@ export function CommandPalette() {
return ( return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}> <Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[20%] w-[min(40rem,calc(100vw-2rem))] max-w-none translate-y-0 gap-0 p-0 sm:max-w-none" showCloseButton={false}> <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> <DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription> <DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2 border-b px-3"> <div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input <input
autoFocus autoFocus
value={query} value={query}
aria-label="Поиск и команды" aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…" placeholder="Раздел, пользователь, приложение или действие…"
className="h-11 min-w-0 flex-1 bg-transparent text-sm outline-none" className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
setActive(0) setActive(0)
@@ -584,14 +605,16 @@ export function CommandPalette() {
/> />
<Kbd>Esc</Kbd> <Kbd>Esc</Kbd>
</div> </div>
<div className="max-h-[50svh] overflow-y-auto p-1.5" role="listbox" aria-label="Результаты"> <div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? ( {groups.length === 0 ? (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">Ничего не найдено</p> <p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : ( ) : (
groups.map(([group, list]) => ( groups.map(([group, list]) => (
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
<div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{group}</div> <div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => ( {list.map((e) => {
const Icon = e.icon
return (
<button <button
key={e.id} key={e.id}
type="button" type="button"
@@ -602,24 +625,24 @@ export function CommandPalette() {
e.run() e.run()
close() close()
}} }}
className={cn( 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')}
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm outline-none',
e.index === active && 'bg-accent text-accent-foreground',
)}
> >
{group === 'Действия' ? <UserPlusIcon className="size-4" aria-hidden /> : null} {Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate">{e.label}</span> {e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="text-muted-foreground truncate text-xs">{e.hint}</span> <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> </button>
))} )
})}
</div> </div>
)) ))
)} )}
</div> </div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-3 py-2 text-xs"> <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>↵ открыть</span> <span>↵ открыть</span>
<span>Esc закрыть</span> <span>Esc закрыть</span>
<span className="ml-auto">Подсказка: «неудачные входы ops за сутки»</span>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AdminUser } from '@authportal/shared'
import { MOCK_USERS } from './mock'
import { import {
PreviewStoreContext, PreviewStoreContext,
type Contrast, type Contrast,
type Density, type Density,
type PendingChange, type PendingChange,
type RowMark,
type PreviewStore, type PreviewStore,
} from './preview-store' } from './preview-store'
@@ -15,6 +18,7 @@ import {
export function PreviewProvider({ children }: { children: ReactNode }) { export function PreviewProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingChange[]>([]) const [pending, setPending] = useState<PendingChange[]>([])
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
const [density, setDensity] = useState<Density>('comfortable') const [density, setDensity] = useState<Density>('comfortable')
const [contrast, setContrast] = useState<Contrast>('standard') const [contrast, setContrast] = useState<Contrast>('standard')
const [pins, setPins] = useState<string[]>(() => { const [pins, setPins] = useState<string[]>(() => {
@@ -44,11 +48,11 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
root.dataset.previewDensity = density root.dataset.density = density
root.dataset.previewContrast = contrast root.dataset.contrast = contrast
return () => { return () => {
delete root.dataset.previewDensity delete root.dataset.density
delete root.dataset.previewContrast delete root.dataset.contrast
} }
}, [density, contrast]) }, [density, contrast])
@@ -77,20 +81,39 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
const applyAll = useCallback(() => { const applyAll = useCallback(() => {
const count = pendingRef.current.length const count = pendingRef.current.length
for (const c of pendingRef.current) c.commit?.()
setPending([]) setPending([])
toast.success(`Сохранено изменений: ${count}`, { toast.success(`Сохранено изменений: ${count}`, {
description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).', description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).',
}) })
}, []) }, [])
const marksFor = useCallback(
(resource: string) => {
const out: Record<string, RowMark> = {}
for (const c of pending) {
if (c.resource !== resource || !c.marks) continue
for (const [id, mark] of Object.entries(c.marks)) {
// «Будет удалено» сильнее остальных пометок.
if (out[id] !== 'deleted') out[id] = mark
}
}
return out
},
[pending],
)
const value = useMemo<PreviewStore>( const value = useMemo<PreviewStore>(
() => ({ () => ({
pending, pending,
marksFor,
addChange, addChange,
discardAll, discardAll,
applyAll, applyAll,
paletteOpen, paletteOpen,
setPaletteOpen, setPaletteOpen,
users,
setUsers,
pins, pins,
togglePin, togglePin,
density, density,
@@ -98,7 +121,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
contrast, contrast,
setContrast, setContrast,
}), }),
[pending, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast], [pending, marksFor, addChange, discardAll, applyAll, paletteOpen, users, pins, togglePin, density, contrast],
) )
return ( return (
@@ -4,7 +4,6 @@ import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar
import { TooltipProvider } from '@authportal/ui/components/tooltip' import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome' import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
import { UnsavedBar } from './unsaved-bar' import { UnsavedBar } from './unsaved-bar'
import './design-preview.css'
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */ /** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
export function PreviewShell({ children }: { children: ReactNode }) { export function PreviewShell({ children }: { children: ReactNode }) {
+16 -1
View File
@@ -1,11 +1,21 @@
import { createContext, useContext } from 'react' import { createContext, useContext, type Dispatch, type SetStateAction } from 'react'
import type { AdminUser } from '@authportal/shared'
export type ChangeKind = 'add' | 'chg' | 'del' export type ChangeKind = 'add' | 'chg' | 'del'
import type { RowMark } from '@/components/portal/tone'
export type { RowMark }
export type PendingChange = { export type PendingChange = {
id: string id: string
kind: ChangeKind kind: ChangeKind
title: string title: string
/** Раздел, к которому относятся пометки строк («users», «app-links»). */
resource?: string
/** Пометки строк: «Не сохранено» (dirty/new) и «Будет удалено» (deleted). */
marks?: Record<string, RowMark>
/** Выполняется при «Сохранить»: окончательно применяет правку (например, убирает удалённую строку). */
commit?: () => void
/** Строки diff: «+ …», «− …», « … ». */ /** Строки diff: «+ …», «− …», « … ». */
lines: string[] lines: string[]
/** Откатывает локальное состояние страницы при «Отменить». */ /** Откатывает локальное состояние страницы при «Отменить». */
@@ -17,11 +27,16 @@ export type Contrast = 'standard' | 'high'
export type PreviewStore = { export type PreviewStore = {
pending: PendingChange[] pending: PendingChange[]
/** Пометки строк раздела из накопленных правок. */
marksFor: (resource: string) => Record<string, RowMark>
addChange: (change: Omit<PendingChange, 'id'>) => void addChange: (change: Omit<PendingChange, 'id'>) => void
discardAll: () => void discardAll: () => void
applyAll: () => void applyAll: () => void
paletteOpen: boolean paletteOpen: boolean
setPaletteOpen: (open: boolean) => void setPaletteOpen: (open: boolean) => void
/** Каталог пользователей: общий для списка и профиля. */
users: AdminUser[]
setUsers: Dispatch<SetStateAction<AdminUser[]>>
pins: string[] pins: string[]
togglePin: (id: string) => void togglePin: (id: string) => void
density: Density density: Density
@@ -1,289 +0,0 @@
import { useMemo, useState, type ReactNode } from 'react'
import { SearchIcon } from 'lucide-react'
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 {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
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 { Separator } from '@authportal/ui/components/separator'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits'
import type { Tone } from './mock'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
/**
* Секция-таблица макета: Frame + Filters-подобная панель + DataGrid на DataGridScrollArea.
* Минимальная ширина таблицы = сумма колонок, поэтому на узком экране таблица
* прокручивается по горизонтали внутри контейнера, а не ломает страницу.
*/
export function PreviewSectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = false,
bulkActions,
toolbar,
onReorder,
pageSize = 8,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode
toolbar?: ReactNode
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
pageSize?: number
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
})
}
for (const col of columns) {
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title },
})
}
return list
}, [columns, selectable, onReorder])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
const minWidth = columns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0),
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const paged = data.length > pageSize
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }}
>
<Frame spacing="sm" className={cn('w-full min-w-0', className)}>
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none">
{views || searchText || toolbar ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{views ? (
<Segmented
label="Вид"
options={views.map((v) => `${v.label} · ${rows.filter(v.match).length}`)}
value={view}
onChange={(i) => {
setView(i)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
) : null}
{searchText ? (
<InputGroup className="w-56 max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
</div>
{toolbar ? <div className="flex flex-wrap items-center gap-2">{toolbar}</div> : null}
</div>
<Separator />
</>
) : null}
{selectable && selectedIds.length > 0 && bulkActions ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2">
<span className="text-sm font-medium">Выбрано: {selectedIds.length}</span>
<div className="flex flex-wrap items-center gap-2">
{bulkActions(selectedIds, () => setRowSelection({}))}
<Button type="button" size="sm" variant="ghost" onClick={() => setRowSelection({})}>
Сбросить
</Button>
</div>
</div>
<Separator />
</>
) : null}
<DataGridScrollArea>
<div className="pb-3" 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">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
{paged ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
+3 -3
View File
@@ -37,13 +37,13 @@ export function UnsavedBar() {
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал <b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setDiffOpen(true)}> <Button type="button" variant="outline" onClick={() => setDiffOpen(true)}>
Просмотреть diff Просмотреть diff
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}> <Button type="button" variant="outline" onClick={discardAll}>
Отменить Отменить
</Button> </Button>
<Button type="button" size="sm" onClick={applyAll}> <Button type="button" onClick={applyAll}>
Сохранить Сохранить
</Button> </Button>
</div> </div>
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef } from 'react'
import type { AdminUser } from '@authportal/shared'
import { usePreviewStore } from './preview-store'
/** Меняет поля у выбранных пользователей и кладёт правку в «Не сохранено» (с откатом). */
export function usePatchUsers() {
const { users, setUsers, addChange } = usePreviewStore()
const usersRef = useRef(users)
useEffect(() => {
usersRef.current = users
}, [users])
return useCallback(
(
ids: string[],
apply: (u: AdminUser) => AdminUser,
title: string,
lineFor: (before: AdminUser, after: AdminUser) => string[],
) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
resource: 'users',
marks: Object.fromEntries(before.map((b) => [b.id, 'dirty' as const])),
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange, setUsers],
)
}
+53 -75
View File
@@ -1,25 +1,11 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import {
MoreHorizontalIcon,
PauseCircleIcon,
PlayCircleIcon,
ScrollTextIcon,
ShieldCheckIcon,
Trash2Icon,
FingerprintIcon,
} from 'lucide-react'
import type { AdminUser } from '@authportal/shared' import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import { PendingChip, StatusBadge } from '@/components/portal/bits'
DropdownMenu, import type { RowMark } from './preview-store'
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { StatusBadge } from './bits'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock' 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 } { function userStatus(u: AdminUser): { tone: Tone; label: string } {
if (u.disabled) return { tone: 'mu', label: 'Отключён' } if (u.disabled) return { tone: 'mu', label: 'Отключён' }
@@ -41,15 +27,15 @@ function passkeyLabel(n: number) {
export type UsersGridHandlers = { export type UsersGridHandlers = {
onOpenAccess: (u: AdminUser) => void onOpenAccess: (u: AdminUser) => void
onOpenJournal: (u: AdminUser) => void
onToggleDisabled: (u: AdminUser) => void onToggleDisabled: (u: AdminUser) => void
onResetPasskeys: (u: AdminUser) => void
onDelete: (u: AdminUser) => void onDelete: (u: AdminUser) => void
onBulkDisabled: (ids: string[], disabled: boolean) => void onBulkDisabled: (ids: string[], disabled: boolean) => void
onBulkDelete: (ids: string[]) => void
onBulkResetPasskeys: (ids: string[]) => void
} }
/** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */ /** Каталог пользователей по доске: участник, статус, роль, приложения, passkey, активность · IP. */
export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: UsersGridHandlers }) { export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) {
const columns = useMemo<GridColumn<AdminUser>[]>( const columns = useMemo<GridColumn<AdminUser>[]>(
() => [ () => [
{ {
@@ -59,80 +45,72 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U
sortValue: (u) => u.email, sortValue: (u) => u.email,
cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />, cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />,
}, },
{ id: 'status', title: 'Статус', size: 140, cell: (u) => <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge> },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="text-sm">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate text-sm">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="text-sm">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[13px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
{ {
id: 'menu', id: 'status',
title: '', title: 'Статус',
size: 56, size: 150,
cell: (u) => ( cell: (u) => (
<div onClick={(e) => e.stopPropagation()}> <div className="flex flex-wrap items-center gap-1.5">
<DropdownMenu> <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge>
<DropdownMenuTrigger {marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${u.email}`} />}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => handlers.onOpenAccess(u)}>
<ShieldCheckIcon aria-hidden />
Права доступа
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onOpenJournal(u)}>
<ScrollTextIcon aria-hidden />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onToggleDisabled(u)}>
{u.disabled ? <PlayCircleIcon aria-hidden /> : <PauseCircleIcon aria-hidden />}
{u.disabled ? 'Включить' : 'Отключить'}
</DropdownMenuItem>
<DropdownMenuItem disabled={u.passkey_count === 0} onClick={() => handlers.onResetPasskeys(u)}>
<FingerprintIcon aria-hidden />
Сбросить passkeys…
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handlers.onDelete(u)}>
<Trash2Icon aria-hidden />
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
), ),
}, },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="font-mono text-[12.5px]">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate font-mono text-[12.5px]">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="font-mono text-[12.5px]">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px]">{USER_META[u.id]?.activity ?? '—'}</span> },
], ],
[handlers], [marks],
) )
return ( return (
<PreviewSectionGrid <SectionGrid
title="Пользователи портала" title="Пользователи портала"
description="Поиск по email и имени · виды по статусу · строка открывает права доступа" description="Поиск по email и имени · фильтры: роль, статус, приложения, passkey · колонки настраиваются, вид сохраняется"
rows={users} rows={users}
columns={columns} columns={columns}
getRowId={(u) => u.id} getRowId={(u) => u.id}
selectable
searchText={(u) => `${u.email} ${u.name}`} searchText={(u) => `${u.email} ${u.name}`}
searchPlaceholder="Email или имя"
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Активные', match: (u) => !u.disabled }, { label: 'Активные', match: (u) => !u.disabled && u.passkey_count > 0 },
{ label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 }, { label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 },
{ label: 'Отключённые', match: (u) => u.disabled }, { label: 'Отключённые', match: (u) => u.disabled },
]} ]}
onRowClick={handlers.onOpenAccess} onRowClick={handlers.onOpenAccess}
bulkActions={(ids, clear) => ( rowStatus={(u) => marks[u.id]}
<> rowSwitch={{ value: (u) => !u.disabled, onChange: (u) => handlers.onToggleDisabled(u) }}
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, false); clear() }}> rowMenu={(u) => ({
Включить onOpen: () => handlers.onOpenAccess(u),
toggleLabel: u.disabled ? 'Включить' : 'Выключить',
onToggle: () => handlers.onToggleDisabled(u),
onDuplicate: () => toast('Дублирование пользователя недоступно в макете'),
onDelete: () => handlers.onDelete(u),
})}
card={(u) => ({
name: u.email,
sub: USER_META[u.id]?.sub ?? u.name,
tone: userStatus(u).tone,
status: userStatus(u).label,
cells: [
{ l: 'Роль', v: u.is_admin ? 'Админ' : 'Пользователь' },
{ l: 'Приложения', v: appsSummary(u) },
{ l: 'Passkey', v: passkeyLabel(u.passkey_count) },
{ l: 'Активность · IP', v: USER_META[u.id]?.activity ?? '—' },
],
})}
bulk={{
onLabel: 'Включить',
offLabel: 'Отключить',
onRun: (ids) => handlers.onBulkDisabled(ids, false),
offRun: (ids) => handlers.onBulkDisabled(ids, true),
onDelete: handlers.onBulkDelete,
}}
tools={(ids) => (
<Button type="button" variant="outline" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
Сбросить passkeys…
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, true); clear() }}>
Отключить
</Button>
</>
)} )}
/> />
) )
+1
View File
@@ -6,6 +6,7 @@ import { ThemeProvider } from 'next-themes'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import '@authportal/ui/globals.css' import '@authportal/ui/globals.css'
import './portal-theme.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { 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;
}
}
+22 -21
View File
@@ -30,8 +30,8 @@ import { Route as DesignPreviewShellAppsRouteImport } from './routes/design-prev
import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit' import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit'
import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins' import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins'
import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc' import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc'
import { Route as DesignPreviewShellUsersRouteImport } from './routes/design-preview._shell.users'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId' import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
import { Route as DesignPreviewShellUsersIndexRouteImport } from './routes/design-preview._shell.users.index'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
@@ -139,16 +139,17 @@ const DesignPreviewShellOidcRoute = DesignPreviewShellOidcRouteImport.update({
path: '/oidc', path: '/oidc',
getParentRoute: () => DesignPreviewShellRoute, getParentRoute: () => DesignPreviewShellRoute,
} as any) } as any)
const DesignPreviewShellUsersRoute = DesignPreviewShellUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId', id: '/users/$userId',
path: '/users/$userId', path: '/users/$userId',
getParentRoute: () => AuthAdminRoute, getParentRoute: () => AuthAdminRoute,
} as any) } as any)
const DesignPreviewShellUsersIndexRoute =
DesignPreviewShellUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -169,9 +170,9 @@ export interface FileRoutesByFullPath {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute '/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin/': typeof AuthAdminIndexRoute '/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users/': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -190,9 +191,9 @@ export interface FileRoutesByTo {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute '/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin': typeof AuthAdminIndexRoute '/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -216,9 +217,9 @@ export interface FileRoutesById {
'/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute '/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/_shell/users': typeof DesignPreviewShellUsersRoute
'/_auth/admin/': typeof AuthAdminIndexRoute '/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/_shell/users/': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -241,9 +242,9 @@ export interface FileRouteTypes {
| '/design-preview/audit' | '/design-preview/audit'
| '/design-preview/logins' | '/design-preview/logins'
| '/design-preview/oidc' | '/design-preview/oidc'
| '/design-preview/users'
| '/admin/' | '/admin/'
| '/admin/users/$userId' | '/admin/users/$userId'
| '/design-preview/users/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -262,9 +263,9 @@ export interface FileRouteTypes {
| '/design-preview/audit' | '/design-preview/audit'
| '/design-preview/logins' | '/design-preview/logins'
| '/design-preview/oidc' | '/design-preview/oidc'
| '/design-preview/users'
| '/admin' | '/admin'
| '/admin/users/$userId' | '/admin/users/$userId'
| '/design-preview/users'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -287,9 +288,9 @@ export interface FileRouteTypes {
| '/design-preview/_shell/audit' | '/design-preview/_shell/audit'
| '/design-preview/_shell/logins' | '/design-preview/_shell/logins'
| '/design-preview/_shell/oidc' | '/design-preview/_shell/oidc'
| '/design-preview/_shell/users'
| '/_auth/admin/' | '/_auth/admin/'
| '/_auth/admin/users/$userId' | '/_auth/admin/users/$userId'
| '/design-preview/_shell/users/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -448,13 +449,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DesignPreviewShellOidcRouteImport preLoaderRoute: typeof DesignPreviewShellOidcRouteImport
parentRoute: typeof DesignPreviewShellRoute parentRoute: typeof DesignPreviewShellRoute
} }
'/design-preview/_shell/users': {
id: '/design-preview/_shell/users'
path: '/users'
fullPath: '/design-preview/users'
preLoaderRoute: typeof DesignPreviewShellUsersRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/_auth/admin/users/$userId': { '/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId' id: '/_auth/admin/users/$userId'
path: '/users/$userId' path: '/users/$userId'
@@ -462,6 +456,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport
parentRoute: typeof AuthAdminRoute parentRoute: typeof AuthAdminRoute
} }
'/design-preview/_shell/users/': {
id: '/design-preview/_shell/users/'
path: '/users'
fullPath: '/design-preview/users/'
preLoaderRoute: typeof DesignPreviewShellUsersIndexRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
} }
} }
@@ -508,7 +509,7 @@ interface DesignPreviewShellRouteChildren {
DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
DesignPreviewShellUsersRoute: typeof DesignPreviewShellUsersRoute DesignPreviewShellUsersIndexRoute: typeof DesignPreviewShellUsersIndexRoute
} }
const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = { const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
@@ -518,7 +519,7 @@ const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute, DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute, DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute, DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
DesignPreviewShellUsersRoute: DesignPreviewShellUsersRoute, DesignPreviewShellUsersIndexRoute: DesignPreviewShellUsersIndexRoute,
} }
const DesignPreviewShellRouteWithChildren = const DesignPreviewShellRouteWithChildren =
+269 -14
View File
@@ -1,25 +1,280 @@
/** import { useMemo, useState } from 'react'
* Account security — passkeys.
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
*/
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel' 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')({ export const Route = createFileRoute('/_auth/account')({
component: AccountPage, 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() { 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 ( return (
<PageShell> <>
<div className="flex flex-col gap-1"> <PageHead
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1> title="Безопасность"
<p className="text-muted-foreground text-sm"> sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
Passkey как альтернатива паролю. Пароль остаётся запасным входом. actions={
</p> <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> </div>
<PasskeySettingsPanel /> ) : (
</PageShell> <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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { defaultAppSwitcherConfig } from '@authportal/shared' 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 { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
import { import {
Frame, Frame,
@@ -47,13 +47,8 @@ function AdminAppsPage() {
}) })
return ( return (
<PageShell> <>
<div className="flex flex-col gap-px"> <PageHead title="Ссылки приложений" sub="URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)" />
<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>
<Frame dense spacing="sm" className="w-full"> <Frame dense spacing="sm" className="w-full">
<FrameHeader> <FrameHeader>
@@ -84,6 +79,6 @@ function AdminAppsPage() {
)} )}
</FramePanel> </FramePanel>
</Frame> </Frame>
</PageShell> </>
) )
} }
+236 -67
View File
@@ -1,105 +1,274 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { import type { AuditLogEntry, AuditPurgeResponse, AuditSettings, AuditSeverity } from '@authportal/shared'
AuditLogEntry, import { actionMeta, exportAuditCsv, severityLabel, sourceAppLabel } from '@/components/reui-kit/audit-log-helpers'
AuditPurgeResponse, import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
AuditSettings, import { ActivityFeed } from '@/components/portal/canvas-blocks'
} from '@authportal/shared' import { fmtTime, fmtWhen } from '@/components/portal/format'
import { PageShell } from '@/components/page-shell' import { kpi } from '@/components/portal/kpi'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline' import { KpiStrip } from '@/components/portal/kpi-strip'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel' import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { api, ApiError } from '@/lib/api-client' import type { Tone } from '@/components/portal/tone'
import {
auditQueryKey,
auditQueryOptions,
auditSettingsQueryKey,
auditSettingsQueryOptions,
} from '@/queries/audit'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Button } from '@authportal/ui/components/button' 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')({ export const Route = createFileRoute('/_auth/admin/audit')({
component: AdminAuditPage, 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() { function AdminAuditPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { const [tab, setTab] = useState('feed')
data: entries = [], const [entry, setEntry] = useState<Row | null>(null)
isLoading, const [purgeOpen, setPurgeOpen] = useState(false)
error, const [daysDraft, setDaysDraft] = useState<string | null>(null)
refetch,
} = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 })) const entriesQ = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
const { data: settings } = useQuery(auditSettingsQueryOptions) const { data: settings } = useQuery(auditSettingsQueryOptions)
const retention = settings?.retention_days ?? 90
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (retention_days: number) => mutationFn: (retention_days: number) => api.put<AuditSettings>('/api/v1/admin/audit/settings', { retention_days }),
api.put<AuditSettings>('/api/v1/admin/audit/settings', {
retention_days,
}),
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey }) await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey })
await queryClient.invalidateQueries({ queryKey: auditQueryKey }) await queryClient.invalidateQueries({ queryKey: auditQueryKey })
setDaysDraft(null)
toast.success(`Срок хранения: ${data.retention_days} дн.`) toast.success(`Срок хранения: ${data.retention_days} дн.`)
}, },
onError: (err) => { onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось сохранить'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось сохранить',
)
},
}) })
const purgeMutation = useMutation({ const purgeMutation = useMutation({
mutationFn: () => mutationFn: () => api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
onSuccess: async (data) => { onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: auditQueryKey }) await queryClient.invalidateQueries({ queryKey: auditQueryKey })
setPurgeOpen(false)
toast.success(`Удалено записей: ${data.deleted}`) toast.success(`Удалено записей: ${data.deleted}`)
}, },
onError: (err) => { onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки'),
toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки')
},
}) })
return ( const rows = useMemo<Row[]>(
<PageShell> () =>
<div className="flex flex-col gap-6"> entries.map((e) => ({
<AuditRetentionPanel id: e.id,
retentionDays={settings?.retention_days ?? 90} action: e.action,
saving={saveMutation.isPending} summary: e.summary,
purging={purgeMutation.isPending} tone: SEVERITY_TONE[e.severity],
onSave={(days) => saveMutation.mutate(days)} label: severityLabel[e.severity],
onPurge={() => purgeMutation.mutate()} source: sourceAppLabel(e.source_app),
/> who: e.actor_email?.split('@')[0] ?? 'system',
ip: e.ip ?? '—',
when: fmtWhen(e.created_at),
entry: e,
})),
[entries],
)
{error ? ( const feed = useMemo(
<div className="flex flex-col gap-2"> () =>
<p className="text-destructive text-sm"> rows.slice(0, 5).map((r) => ({
{error instanceof ApiError ? error.message : 'Ошибка загрузки'} t: fmtTime(r.entry.created_at),
</p> title: actionMeta(r.action).label,
<Button text: `${r.summary} · ${r.who} · ${r.source}`,
variant="outline" label: r.label,
size="sm" tone: r.tone,
className="w-fit" })),
onClick={() => refetch()} [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>
<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> </Button>
</div> </div>
) : isLoading ? ( ) : null}
<div className="flex flex-col gap-3"> <KpiStrip
{Array.from({ length: 6 }).map((_, i) => ( aria-label="Показатели"
<Skeleton key={i} className="h-16 w-full" /> items={[
))} kpi('all', 'Событий', String(stats.all), { icon: HistoryIcon, badge: 'в выборке' }),
</div> 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" />
) : ( ) : (
<AuditLogTimeline <ActivityFeed
entries={entries as AuditLogEntry[]} title="Лента изменений"
totalCount={entries.length} 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> </div>
</PageShell> </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 { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ShieldCheckIcon, UserCheckIcon, UserXIcon, UsersIcon } from 'lucide-react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared' 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 { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet' import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
@@ -174,8 +177,21 @@ function AdminUsersPage() {
[invalidateUsers], [invalidateUsers],
) )
const admins = users.filter((u) => u.is_admin).length
const disabled = users.filter((u) => u.disabled).length
return ( 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 ? ( {error ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-destructive text-sm"> <p className="text-destructive text-sm">
@@ -229,6 +245,6 @@ function AdminUsersPage() {
if (!next) setAuditUser(null) if (!next) setAuditUser(null)
}} }}
/> />
</PageShell> </>
) )
} }
+267 -494
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 { 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
type ColumnDef,
type PaginationState,
type SortingState,
useTable,
} from '@tanstack/react-table'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AdminSession, AuditLogEntry } from '@authportal/shared' import type { AdminSession, AuditLogEntry } from '@authportal/shared'
import { import { actionMeta, detailString, exportAuditCsv, matchesRange, sourceAppLabel, type AuditRange } from '@/components/reui-kit/audit-log-helpers'
KeyRoundIcon, import { ExplainBanner } from '@/components/portal/explain-banner'
LogInIcon, import { CodeBlock, KvList, PageHead, PageTab, PageTabsList, PortalSheet, Segmented, StatusBadge } from '@/components/portal/bits'
ShieldAlertIcon, import { fmtWhen } from '@/components/portal/format'
Trash2Icon, import { kpi } from '@/components/portal/kpi'
} from 'lucide-react' import { KpiStrip } from '@/components/portal/kpi-strip'
import { PageShell } from '@/components/page-shell' import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import type { Tone } from '@/components/portal/tone'
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 { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { import { api, ApiError } from '@/lib/api-client'
Select, import { auditQueryOptions } from '@/queries/audit'
SelectContent, import { sessionsListQueryKey, sessionsQueryOptions } from '@/queries/sessions'
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers'
export const Route = createFileRoute('/_auth/admin/logins')({ export const Route = createFileRoute('/_auth/admin/logins')({
component: AdminLoginsPage, 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) { type LoginRow = {
if (action === 'auth.login_failed') return 'warning-outline' as const id: string
if (action === 'auth.sso_handoff') return 'info-outline' as const login: string
return 'success-outline' as const 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() { function AdminLoginsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [tab, setTab] = useState<'history' | 'sessions'>('history') const navigate = useNavigate()
const [resultFilter, setResultFilter] = useState<ResultFilter>('all') const [range, setRange] = useState(0)
const [range, setRange] = useState<AuditRange>('7d') const [event, setEvent] = useState<LoginRow | null>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true },
])
const { const entriesQ = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
data: entries = [], const sessionsQ = useQuery(sessionsQueryOptions())
isLoading, const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
error, const sessions = useMemo(() => sessionsQ.data ?? [], [sessionsQ.data])
refetch, const period = PERIODS[range]!.value
} = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
const { const revoke = useMutation({
data: sessions = [],
isLoading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = useQuery(sessionsQueryOptions())
const revokeMutation = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`), mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() }) await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() })
toast.success('Сессия отозвана') toast.success('Сессия завершена')
}, },
onError: (err) => { onError: (err) => toast.error(err instanceof ApiError ? err.message : 'Не удалось завершить сессию'),
toast.error( })
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
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 filtered = useMemo(() => { const sessionRows = useMemo<SessionRow[]>(
return entries.filter((e) => { () =>
if (!matchesRange(e, range)) return false sessions.map((s: AdminSession) => ({
if (resultFilter === 'success') { id: s.id,
return e.action === 'auth.login' || e.action === 'auth.sso_handoff' user: s.email,
} note: s.name,
if (resultFilter === 'failed') return e.action === 'auth.login_failed' ipClient: `${s.ip ?? '—'} · ${shortUa(s.user_agent)}`,
return true created: fmtWhen(s.created_at),
}) expires: fmtWhen(s.expires_at),
}, [entries, range, resultFilter]) })),
[sessions],
)
const kpi = useMemo(() => { const stats = useMemo(() => {
const day = entries.filter((e) => matchesRange(e, '24h')) const day = entries.filter((e) => matchesRange(e, '24h'))
const ok = day.filter( return {
(e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff', ok: day.filter((e) => isOk(e.action)).length,
).length fail: day.filter((e) => e.action === FAILED).length,
const fail = day.filter((e) => e.action === 'auth.login_failed').length ips: new Set(day.map((e) => e.ip).filter(Boolean)).size,
const ips = new Set( users: new Set(sessions.map((s) => s.user_id)).size,
day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)), }
) }, [entries, sessions])
return { ok, fail, ips: ips.size, sessions: sessions.length }
}, [entries, sessions.length])
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>[]>(
() => [ () => [
{ { id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
accessorKey: 'created_at', { id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
header: ({ column }) => ( { 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> },
<DataGridColumnHeader title="Время" column={column} /> { 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> },
cell: ({ row }) => ( { id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
<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,
},
], ],
[], [],
) )
const historyTable = useTable({ const sessionCols = useMemo<GridColumn<SessionRow>[]>(
features: dataGridFeatures,
data: filtered,
columns: historyColumns,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
const sessionColumns = useMemo<ColumnDef<DataGridFeatures, AdminSession>[]>(
() => [ () => [
{ { id: 'user', title: 'Пользователь', size: 260, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot="ok" title={r.user} sub={r.note} /> },
accessorKey: 'email', { id: 'status', title: 'Статус', size: 110, cell: () => <StatusBadge tone="ok">Активна</StatusBadge> },
header: ({ column }) => ( { id: 'ip', title: 'IP · клиент', size: 280, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
<DataGridColumnHeader title="Пользователь" column={column} /> { 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> },
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,
},
], ],
[revokeMutation], [],
) )
const [sessionPagination, setSessionPagination] = useState<PaginationState>({ const loading = entriesQ.isLoading || sessionsQ.isLoading
pageIndex: 0, const failedQuery = entriesQ.isError || sessionsQ.isError
pageSize: 20,
})
const sessionsTable = useTable({
features: dataGridFeatures,
data: sessions,
columns: sessionColumns,
state: { pagination: sessionPagination },
onPaginationChange: setSessionPagination,
})
return ( return (
<PageShell> <>
<div className="flex flex-col gap-6"> <PageHead
<KpiStatGrid title="Входы"
sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={
<Button
variant="outline"
disabled={rows.length === 0}
onClick={() => {
exportAuditCsv(rows.map((r) => r.entry))
toast.success(`CSV сформирован: ${rows.length} записей`)
}}
>
Экспорт 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={[ items={[
{ kpi('ok', 'Успешные (24ч)', String(stats.ok), { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
id: 'ok', kpi('fail', 'Неудачи (24ч)', String(stats.fail), { icon: ShieldAlertIcon, tone: stats.fail ? 'er' : 'neutral', badge: 'неверный пароль и др.' }),
label: 'Успешные (24ч)', kpi('ip', 'Уникальные IP (24ч)', String(stats.ips), { icon: GlobeIcon, badge: 'по журналу' }),
value: kpi.ok, kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: `${stats.users} пользователей` }),
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 defaultValue="hist" className="min-w-0">
<Tabs <PageTabsList>
value={tab} <PageTab value="hist" label="История" count={rows.length} />
onValueChange={(v) => { <PageTab value="sess" label="Активные сессии" count={sessions.length} />
if (v === 'history' || v === 'sessions') setTab(v) </PageTabsList>
}} <TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
> {loading ? (
<TabsList <Skeleton className="h-72 w-full rounded-xl" />
variant="line" ) : (
className="h-10! w-full justify-start gap-6 border-b" <SectionGrid
> title="Журнал входов"
<TabsTrigger value="history" className="px-1 text-sm after:-bottom-px!"> description="Поиск по логину и IP · виды по результату · период выбирается выше"
История rows={rows}
</TabsTrigger> columns={loginCols}
<TabsTrigger value="sessions" className="px-1 text-sm after:-bottom-px!"> getRowId={(r) => r.id}
Активные сессии searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
<span className="bg-muted text-muted-foreground ms-2 rounded-md px-1.5 text-xs tabular-nums"> searchPlaceholder="Логин или IP"
{sessions.length} selectable={false}
</span> views={[
</TabsTrigger> { label: 'Все', match: () => true },
</TabsList> { 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> </Tabs>
{tab === 'history' ? ( <PortalSheet
<div className="flex flex-col gap-4"> open={event !== null}
<div className="flex flex-wrap items-center gap-2"> onOpenChange={(o) => !o && setEvent(null)}
<Tabs title="Событие входа"
value={resultFilter} description="Детали записи журнала"
onValueChange={(v) => { footer={
if (v === 'all' || v === 'success' || v === 'failed') { <>
setResultFilter(v) <Button variant="outline" onClick={() => setEvent(null)}>
Закрыть
</Button>
<Button
variant="outline"
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>
</>
} }
}}
> >
<TabsList variant="line" className="h-auto gap-4 border-0"> {event ? (
<TabsTrigger value="all" className="px-0 pb-1 text-sm"> <>
Все <div>
</TabsTrigger> <StatusBadge tone={event.tone}>{event.status}</StatusBadge>
<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> </div>
<KvList
{error ? ( items={[
<div className="flex flex-col gap-2"> ['Логин', event.login],
<p className="text-destructive text-sm"> ['IP', event.ip],
{error instanceof ApiError ? error.message : 'Ошибка загрузки'} ['Клиент', event.client],
</p> ['Приложение', event.app],
<Button ['Причина', event.reason],
variant="outline" ]}
size="sm" />
className="w-fit" <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>
onClick={() => refetch()} </>
> ) : null}
Повторить </PortalSheet>
</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>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => refetchSessions()}
>
Повторить
</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>
) )
} }
+170 -282
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 { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router' 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 { toast } from 'sonner'
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared' import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
import { PageShell } from '@/components/page-shell' import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { import { kpi } from '@/components/portal/kpi'
Frame, import { KpiStrip } from '@/components/portal/kpi-strip'
FrameDescription, import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Badge } from '@/components/reui/badge'
import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet' import { CreateOidcClientSheet } from '@/components/reui-kit/create-oidc-client-sheet'
import { ApiError } from '@/lib/api-client' import { ApiError } from '@/lib/api-client'
import { import { createOidcClient, deleteOidcClient, oidcClientsQueryOptions, oidcMetaQueryOptions, rotateOidcClientSecret } from '@/queries/oidc'
createOidcClient,
deleteOidcClient,
oidcClientsQueryOptions,
oidcMetaQueryOptions,
rotateOidcClientSecret,
} from '@/queries/oidc'
export const Route = createFileRoute('/_auth/admin/oidc')({ export const Route = createFileRoute('/_auth/admin/oidc')({
component: AdminOidcPage, component: AdminOidcPage,
}) })
function copy(value: string) {
void navigator.clipboard.writeText(value)
toast.success('Скопировано')
}
function AdminOidcPage() { function AdminOidcPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: clients, isLoading, isError, error } = useQuery( const { data: clients = [], isLoading, isError, error, refetch } = useQuery(oidcClientsQueryOptions)
oidcClientsQueryOptions,
)
const { data: meta } = useQuery(oidcMetaQueryOptions) const { data: meta } = useQuery(oidcMetaQueryOptions)
const [tab, setTab] = useState('clients')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null) 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({ const createMutation = useMutation({
mutationFn: createOidcClient, mutationFn: createOidcClient,
onSuccess: (created) => { onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) void invalidate()
setCreateOpen(false) setCreateOpen(false)
setSecretOnce(created) setSecretOnce(created)
toast.success('OIDC-клиент создан') toast.success('OIDC-клиент создан')
}, },
onError: (err) => { onError: fail('Не удалось создать клиента'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
)
},
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteOidcClient, mutationFn: deleteOidcClient,
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] }) void invalidate()
setDeleteId(null) setDeleteTarget(null)
setSelected(null)
toast.success('Клиент удалён') toast.success('Клиент удалён')
}, },
onError: (err) => { onError: fail('Не удалось удалить'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось удалить',
)
},
}) })
const rotateMutation = useMutation({ const rotateMutation = useMutation({
mutationFn: rotateOidcClientSecret, mutationFn: rotateOidcClientSecret,
onSuccess: (created) => { onSuccess: (created) => {
setSecretOnce(created) setSecretOnce(created)
toast.success('Секрет обновлён') toast.success('Секрет обновлён')
}, },
onError: (err) => { onError: fail('Не удалось обновить секрет'),
toast.error(
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
)
},
}) })
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> },
],
[],
)
const enabled = clients.filter((c) => c.enabled).length
return ( return (
<PageShell> <>
<div className="flex flex-wrap items-end justify-between gap-3"> <PageHead
<div className="flex flex-col gap-px"> title="OIDC-клиенты"
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1> sub="Внешние сервисы (Technitium DNS и др.) через OpenID Connect · Authorization Code"
<p className="text-muted-foreground text-sm"> actions={
Внешние сервисы (Technitium DNS и др.) через OpenID Connect <Button onClick={() => setCreateOpen(true)}>
</p> <PlusIcon /> Добавить клиента
</div> </Button>
<Button size="sm" onClick={() => setCreateOpen(true)}> }
<PlusIcon className="size-4" /> />
Добавить {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> </Button>
</div> </div>
{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>
) : null} ) : null}
<KpiStrip
<Frame dense className="w-full"> aria-label="Показатели"
<FrameHeader> items={[
<FrameTitle>Клиенты</FrameTitle> kpi('all', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'confidential' }),
<FrameDescription> kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'enabled' }),
Confidential clients (Authorization Code) kpi('off', 'Отключено', String(clients.length - enabled), { icon: ShieldCheckIcon, tone: clients.length - enabled ? 'wn' : 'neutral', badge: 'disabled' }),
</FrameDescription> kpi('iss', 'Issuer', meta ? 'готов' : '—', { icon: LinkIcon, badge: 'discovery + JWKS' }),
</FrameHeader> ]}
<FramePanel> />
<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 ? ( {isLoading ? (
<div className="flex flex-col gap-3"> <Skeleton className="h-64 w-full rounded-xl" />
<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>
) : ( ) : (
<ul className="flex flex-col gap-3"> <SectionGrid
{clients.map((c) => ( title="Клиенты"
<OidcClientRow description="Клик по строке — секрет, redirect URI и удаление"
key={c.id} rows={clients}
client={c} columns={columns}
onRotate={() => rotateMutation.mutate(c.id)} getRowId={(c) => c.id}
onDelete={() => setDeleteId(c.id)} searchText={(c) => `${c.name} ${c.client_id} ${c.redirect_uris.join(' ')}`}
rotating={rotateMutation.isPending} selectable={false}
views={[
{ label: 'Все', match: () => true },
{ label: 'Включённые', match: (c) => c.enabled },
{ label: 'Отключённые', match: (c) => !c.enabled },
]}
onRowClick={setSelected}
emptyMessage="Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS."
/> />
))}
</ul>
)} )}
</FramePanel> </TabsContent>
</Frame> <TabsContent value="issuer">
<FormFrame title="Issuer" description="Metadata для Relying Party" badge="OpenID Connect">
<CreateOidcClientSheet {meta ? (
open={createOpen} <>
onOpenChange={setCreateOpen} {(
pending={createMutation.isPending} [
onSubmit={(values) => createMutation.mutate(values)} ['Issuer', meta.issuer],
/> ['Discovery', meta.discovery_url],
['JWKS', meta.jwks_url],
<SecretRevealDialog ] as const
client={secretOnce} ).map(([label, value]) => (
onClose={() => setSecretOnce(null)} <SettingRow key={label} title={label} description={value}>
/> <Button variant="outline" onClick={() => copy(value)} aria-label={`Копировать ${label}`}>
<CopyIcon /> Копировать
<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> </Button>
</div> </SettingRow>
) ))}
} </>
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>
) : ( ) : (
<Badge variant="warning-light" size="sm"> <Skeleton className="h-24 w-full" />
disabled
</Badge>
)} )}
</div> </FormFrame>
<code className="text-muted-foreground text-xs"> </TabsContent>
{client.client_id} </Tabs>
</code>
</div> <PortalSheet
<div className="flex gap-2"> open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title={selected?.name ?? 'Клиент'}
description="OIDC-клиент"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>
Закрыть
</Button>
<Button <Button
type="button"
size="sm"
variant="outline" variant="outline"
disabled={rotating} disabled={!selected || rotateMutation.isPending}
onClick={onRotate} onClick={() => selected && rotateMutation.mutate(selected.id)}
> >
<KeyRoundIcon className="size-4" /> <KeyRoundIcon /> Новый секрет
Секрет
</Button> </Button>
<Button <Button variant="destructive" onClick={() => selected && setDeleteTarget(selected)}>
type="button" Удалить
size="sm"
variant="ghost"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2Icon className="size-4" />
</Button> </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>
)
}
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"> {selected ? (
<AlertDialogHeader> <>
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle> <div>
<AlertDialogDescription> <StatusBadge tone={selected.enabled ? 'ok' : 'wn'}>{selected.enabled ? 'enabled' : 'disabled'}</StatusBadge>
Секрет показывается только один раз. Вставьте его в Technitium SSO. </div>
</AlertDialogDescription> <KvList items={[['client_id', selected.client_id], ['Scopes', selected.scopes.join(' ')]]} />
</AlertDialogHeader> <CodeBlock label="Redirect URI">{selected.redirect_uris.join('\n')}</CodeBlock>
{client ? ( </>
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs">
{text}
</pre>
) : null} ) : null}
<AlertDialogFooter> </PortalSheet>
<AlertDialogCancel>Закрыть</AlertDialogCancel>
<AlertDialogAction <CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
onClick={() => {
void navigator.clipboard.writeText(text) <PortalSheet
toast.success('Скопировано') 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}
</AlertDialogAction> </PortalSheet>
</AlertDialogFooter>
</AlertDialogContent> <TypedConfirm
</AlertDialog> 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)}
/>
</>
) )
} }
+88 -74
View File
@@ -1,23 +1,24 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
BadgeCheckIcon,
CloudIcon, CloudIcon,
GlobeIcon, GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LayoutGridIcon, LayoutGridIcon,
ServerIcon, ServerIcon,
ShieldIcon, ShieldIcon,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
import type { AppAuthMode, AppId } from '@authportal/shared' import type { AppAuthMode, AppId } from '@authportal/shared'
import { PageShell } from '@/components/page-shell' import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { IconTile } from '@/components/reui/icon-tile' import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { import { kpi } from '@/components/portal/kpi'
Frame, import { KpiStrip } from '@/components/portal/kpi-strip'
FramePanel,
} from '@/components/reui/frame'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ssoOpenApp } from '@/lib/auth' import { ssoOpenApp } from '@/lib/auth'
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth' import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
@@ -35,93 +36,106 @@ const APP_ICONS: Record<AppId, typeof CloudIcon> = {
mm: LayoutDashboardIcon, mm: LayoutDashboardIcon,
} }
async function openApp( async function openApp(baseUrl: string, authMode: AppAuthMode = 'jwt') {
_appId: AppId,
baseUrl: string,
authMode: AppAuthMode = 'jwt',
) {
try { try {
await ssoOpenApp(baseUrl, authMode) await ssoOpenApp(baseUrl, authMode)
} catch { } catch {
toast.error('Не удалось выпустить токен', { description: 'Открываем приложение по базовому адресу — войдите в нём отдельно.' })
window.location.href = baseUrl.replace(/\/$/, '') window.location.href = baseUrl.replace(/\/$/, '')
} }
} }
function AppsPage() { function AppsPage() {
const { data: me, isLoading: meLoading } = useQuery(meQueryOptions) const { data: me, isLoading: meLoading } = useQuery(meQueryOptions)
const { data: catalog, isLoading: catalogLoading } = useQuery( const { data: catalog, isLoading: catalogLoading, isError, refetch } = useQuery(catalogQueryOptions)
catalogQueryOptions,
)
const isLoading = meLoading || catalogLoading
const allowed = new Set(me?.apps ?? [])
const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id))
if (meLoading || catalogLoading) {
return ( return (
<PageShell> <>
<div className="flex flex-wrap items-end justify-between gap-3"> <PageHead title="Приложения" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" />
<div className="flex flex-col gap-1"> <Skeleton className="h-24 w-full rounded-xl" />
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1> <Skeleton className="h-64 w-full rounded-xl" />
<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}
>
Ссылки приложений
</Button>
) : null}
</div>
{isLoading ? ( const all = catalog?.apps ?? []
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> const allowedIds = new Set(me?.apps ?? [])
{Array.from({ length: 3 }).map((_, i) => ( const allowed = all.filter((a) => allowedIds.has(a.id))
<Skeleton key={i} className="h-24 w-full rounded-xl" /> const denied = all.filter((a) => !allowedIds.has(a.id))
))} const modeOf = (a: (typeof all)[number]): AppAuthMode => a.authMode ?? (a.id === 'dns' ? 'oidc' : 'jwt')
</div>
) : apps.length === 0 ? ( const toCard = (app: (typeof all)[number], open: boolean): AppCardItem => {
<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>
</div>
</FramePanel>
</Frame>
) : (
<QuickActionGrid
title=""
actions={apps.map((app) => {
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
const meta = `${app.url.replace(/^https?:\/\//, '')} · ${modeOf(app) === 'oidc' ? 'OIDC' : 'JWT'}`
return { return {
id: app.id, id: app.id,
title: app.title, title: app.title,
description: app.description, desc: app.description,
badgeLabel: 'Открыть', badge: open ? 'Открыть' : 'Нет доступа',
badgeVariant: open ? 'info-light' : 'outline',
meta,
icon: <Icon />, icon: <Icon />,
onSelect: () => onSelect: () =>
void openApp( open
app.id, ? void openApp(app.url, modeOf(app))
app.url, : toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'),
),
} }
})} }
return (
<>
<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>
</div>
) : 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>
) : (
<AppCards
title="Мои приложения"
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
items={allowed.map((a) => toCard(a, true))}
/> />
)} )}
</PageShell> </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 { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell' import { PortalShell } from '@/components/layout/portal-shell'
import { import {
clearToken, clearToken,
getToken, getToken,
@@ -36,8 +36,8 @@ export const Route = createFileRoute('/_auth')({
function AuthLayout() { function AuthLayout() {
return ( return (
<AppShell> <PortalShell>
<Outlet /> <Outlet />
</AppShell> </PortalShell>
) )
} }
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { UsersPage } from '@/design-preview/pages/users-page' import { UsersPage } from '@/design-preview/pages/users-page'
export const Route = createFileRoute('/design-preview/_shell/users')({ export const Route = createFileRoute('/design-preview/_shell/users/')({
component: UsersPage, component: UsersPage,
}) })