fix(design-preview): полоса KPI, баннеры и шапка точно как на досках Auth Portal

Полоса показателей — один Frame с линиями и плашками, колонки по порогам контейнера; баннер в одну строку
с «Объяснить»; бренд «Единый вход · SSO», заголовки групп капсом; пороги монитора в шапке как на доске.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 03:41:00 +07:00
1 parent afbc6fc366
commit cd6516edd6
12 files changed
+165 -138

No files matched your search

+14 -10
View File
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits' import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits'
/** /**
@@ -23,17 +24,20 @@ export function ExplainBanner({
const navigate = useNavigate() const navigate = useNavigate()
return ( return (
<> <>
<SignalAlert <div
tone={tone} role="status"
title={title} className={cn(
action={ 'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}> tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
Объяснить )}
</Button>
}
> >
{children} <div className="min-w-0 flex-1 basis-80 text-pretty">
</SignalAlert> <b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
<PreviewSheet <PreviewSheet
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
+57
View File
@@ -0,0 +1,57 @@
import { Badge } from '@/components/reui/badge'
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-1 @[640px]:grid-cols-2',
3: 'grid-cols-1 @[1240px]:grid-cols-3',
4: 'grid-cols-1 @[640px]:grid-cols-2 @[1240px]:grid-cols-4',
}
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="@container 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) => (
<div key={k.id} className="bg-card flex min-w-0 flex-col gap-1.5 px-[18px] py-3.5">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex items-baseline gap-2">
<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>
</FramePanel>
</Frame>
)
}
+11 -37
View File
@@ -1,46 +1,20 @@
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' export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
const ICON_CLASS: Record<KpiTone, string> = { export type KpiItem = {
neutral: 'text-muted-foreground [&_svg]:text-current', id: string
ok: 'text-success [&_svg]:text-current', label: string
wn: 'text-warning [&_svg]:text-current', value: string
er: 'text-destructive [&_svg]:text-current', /** Плашка-подсказка рядом со значением. */
in: 'text-info [&_svg]:text-current', hint: string
tone: KpiTone
} }
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'warning-light' | 'destructive-light' | 'info-light'> = { /** Плитка KPI доски: подпись, значение (цветом при wn/er) и плашка-подсказка. */
neutral: 'outline',
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
}
/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */
export function kpi( export function kpi(
id: string, id: string,
label: string, label: string,
value: string, value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string }, opts: { icon?: unknown; tone?: KpiTone; badge: string; sub?: string },
): KpiStatItem { ): KpiItem {
const tone = opts.tone ?? 'neutral' return { id, label, value, hint: opts.badge, 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),
}
} }
@@ -2,16 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ChartColumnIcon, ChartColumnIcon,
CloudIcon, CloudIcon,
EyeIcon,
FingerprintIcon,
KeyRoundIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LayoutGridIcon,
NetworkIcon, NetworkIcon,
PlusIcon, PlusIcon,
RotateCcwIcon, RotateCcwIcon,
ServerIcon, ServerIcon,
type LucideIcon, type LucideIcon
} from 'lucide-react' } 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'
@@ -24,11 +20,11 @@ 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 } 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 '../kpi-strip'
import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits' import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { AppCards } from '../canvas-blocks' import { AppCards } from '../canvas-blocks'
import { kpi } from '../kpi' import { kpi } from '../kpi'
@@ -50,7 +46,7 @@ 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 MODES = [ const MODES = [
@@ -64,7 +60,7 @@ 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'
})) }))
export function AppLinksPage() { export function AppLinksPage() {
@@ -92,7 +88,7 @@ export function AppLinksPage() {
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`), lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
resource: 'app-links', resource: 'app-links',
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])), marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
revert: () => setRows(before), revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -115,7 +111,7 @@ export function AppLinksPage() {
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`], lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
resource: 'app-links', resource: 'app-links',
marks: { [item!.id]: 'dirty' }, marks: { [item!.id]: 'dirty' },
revert: () => setRows(before), revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -140,7 +136,7 @@ export function AppLinksPage() {
lines: ['− пользовательский порядок', '+ порядок каталога'], lines: ['− пользовательский порядок', '+ порядок каталога'],
resource: 'app-links', resource: 'app-links',
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])), marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
revert: () => setRows(before), revert: () => setRows(before)
}) })
}, [addChange]) }, [addChange])
@@ -167,7 +163,7 @@ 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.'
}) })
} }
> >
@@ -176,13 +172,13 @@ export function AppLinksPage() {
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }), kpi('n', 'Приложений', String(rows.length), { badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }), kpi('sw', 'В переключателе', String(shown.length), { 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), { 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), { badge: 'Technitium DNS' }),
]} ]}
/> />
<Tabs defaultValue="list" className="min-w-0"> <Tabs defaultValue="list" className="min-w-0">
@@ -215,13 +211,13 @@ export function AppLinksPage() {
onLabel: 'Показать в меню', onLabel: 'Показать в меню',
offLabel: 'Скрыть', offLabel: 'Скрыть',
onRun: (ids) => setEnabled(ids as AppId[], true), onRun: (ids) => setEnabled(ids as AppId[], true),
offRun: (ids) => setEnabled(ids as AppId[], false), offRun: (ids) => setEnabled(ids as AppId[], false)
}} }}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }} rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
rowMenu={(r) => ({ rowMenu={(r) => ({
onOpen: () => setEditing(r), onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню', toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
onToggle: () => setEnabled([r.id], !r.enabled), onToggle: () => setEnabled([r.id], !r.enabled)
})} })}
onReorder={dropOn} onReorder={dropOn}
rowStatus={(r) => marks[r.id]} rowStatus={(r) => marks[r.id]}
@@ -252,7 +248,7 @@ export function AppLinksPage() {
badge: 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),
meta: r.id, meta: r.id,
icon: <Icon />, icon: <Icon />
} }
})} })}
/> />
@@ -1,18 +1,14 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { import {
CloudIcon, CloudIcon,
FingerprintIcon,
GlobeIcon, GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LayoutGridIcon,
ServerIcon, ServerIcon,
ShieldCheckIcon, ShieldIcon
ShieldIcon,
} from 'lucide-react' } 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 { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { PageHead, PageTab, PageTabsList } from '../bits' import { PageHead, PageTab, PageTabsList } from '../bits'
@@ -26,7 +22,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']
@@ -49,7 +45,7 @@ export function AppsPage() {
onSelect: () => onSelect: () =>
open open
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` }) ? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }), : toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
} }
} }
@@ -64,13 +60,13 @@ export function AppsPage() {
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: 'доступ есть', sub: `из ${APPS.length}` }), kpi('avail', 'Доступно приложений', String(allowed.length), { tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'вход без пароля' }), kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }), kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }), kpi('role', 'Роль', 'Админ', { badge: 'is_admin' }),
]} ]}
/> />
<Tabs defaultValue="all" className="min-w-0"> <Tabs defaultValue="all" className="min-w-0">
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { HistoryIcon, NetworkIcon, ShieldAlertIcon, Trash2Icon, UserCogIcon } from 'lucide-react' import { Trash2Icon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
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'
@@ -47,13 +47,13 @@ export function AuditPage() {
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }), kpi('all', 'Событий', '148', { badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }), kpi('acc', 'Изменений прав', '22', { tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', '3', { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }), kpi('crit', 'Критичных', '3', { tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'портал +5 приложений', sub: 'ingest' }), kpi('src', 'Источников', '6', { badge: 'портал +5 приложений' }),
]} ]}
/> />
<Tabs value={tab} onValueChange={setTab} className="min-w-0"> <Tabs value={tab} onValueChange={setTab} className="min-w-0">
@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { DownloadIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '../explain-banner' import { ExplainBanner } from '../explain-banner'
@@ -62,13 +62,13 @@ export function LoginsPage() {
6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи». 6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
</ExplainBanner> </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', { tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }), kpi('fail', 'Неудачи (24ч)', '6', { tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }), kpi('ip', 'Уникальные IP (24ч)', '9', { badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }), kpi('sess', 'Активные сессии', String(sessions.length), { badge: '3 пользователя' }),
]} ]}
/> />
<Tabs defaultValue="hist" className="min-w-0"> <Tabs defaultValue="hist" className="min-w-0">
@@ -107,7 +107,7 @@ export function LoginsPage() {
onToggle: () => { onToggle: () => {
setSessions((s) => s.filter((x) => x.id !== r.id)) setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`) toast.success(`Сессия ${r.user} завершена (макет)`)
}, }
})} })}
/> />
</TabsContent> </TabsContent>
+11 -11
View File
@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CopyIcon, PlusIcon, RefreshCwIcon, AppWindowIcon, PowerIcon, TicketIcon, KeyRoundIcon } from 'lucide-react' import { CopyIcon, PlusIcon, RefreshCwIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import {
AlertDialog, AlertDialog,
@@ -10,7 +10,7 @@ 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'
@@ -26,7 +26,7 @@ import {
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 { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -66,7 +66,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)
@@ -96,13 +96,13 @@ export function OidcPage() {
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления. Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
</ExplainBanner> </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), { 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), { tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }), kpi('tok', 'Токенов за 24 ч', '41', { badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }), kpi('key', 'Ключ подписи', 'RS256', { badge: 'ротация вручную' }),
]} ]}
/> />
<Tabs defaultValue="clients" className="min-w-0"> <Tabs defaultValue="clients" className="min-w-0">
@@ -139,7 +139,7 @@ export function OidcPage() {
rowMenu={(r) => ({ rowMenu={(r) => ({
onOpen: () => setEditing(r), onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Выключить' : 'Включить', toggleLabel: r.enabled ? 'Выключить' : 'Включить',
onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x))), onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))
})} })}
onRowClick={setEditing} onRowClick={setEditing}
/> />
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { KeyRoundIcon, PlusIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
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'
@@ -59,13 +59,13 @@ export function SecurityPage() {
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</ExplainBanner> </ExplainBanner>
) : null} ) : null}
<KpiStatGrid <KpiStrip
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), { tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }), kpi('last', 'Последний вход', 'сегодня', { badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }), kpi('pw', 'Пароль', 'Задан', { badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }), kpi('sess', 'Сессий', '3', { badge: 'устройства' }),
]} ]}
/> />
<Tabs defaultValue="keys" className="min-w-0"> <Tabs defaultValue="keys" className="min-w-0">
@@ -90,7 +90,7 @@ export function SecurityPage() {
onChange: (r, on) => { onChange: (r, on) => {
setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id])) setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id]))
toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)') toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)')
}, }
}} }}
rowMenu={(r) => ({ rowMenu={(r) => ({
onOpen: () => { onOpen: () => {
@@ -99,7 +99,7 @@ export function SecurityPage() {
}, },
toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать', toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать',
onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])), onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])),
onDelete: () => setDeleting(r), onDelete: () => setDeleting(r)
})} })}
onRowClick={(r) => { onRowClick={(r) => {
setSelected(r) setSelected(r)
@@ -1,8 +1,7 @@
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared' import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react' import { UserPlusIcon } from 'lucide-react'
@@ -55,7 +54,7 @@ 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({
@@ -64,7 +63,7 @@ export function UsersPage() {
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)}`],
resource: 'users', resource: 'users',
marks: { [created.id]: 'new' }, marks: { [created.id]: 'new' },
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)), revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id))
}) })
setCreateOpen(false) setCreateOpen(false)
setAccessFor(created) setAccessFor(created)
@@ -85,7 +84,7 @@ export function UsersPage() {
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 admins = users.filter((u) => u.is_admin).length const admins = users.filter((u) => u.is_admin).length
@@ -111,13 +110,13 @@ export function UsersPage() {
> >
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать». 6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner> </ExplainBanner>
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }), kpi('total', 'Пользователей', String(users.length), { badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }), kpi('admins', 'Администраторов', String(admins), { tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }), kpi('nopk', 'Без passkey', String(noPasskey), { tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }), kpi('off', 'Отключённых', String(disabled), { badge: 'вход закрыт' }),
]} ]}
/> />
<Tabs defaultValue="users" className="min-w-0"> <Tabs defaultValue="users" className="min-w-0">
@@ -169,7 +168,7 @@ export function UsersPage() {
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`], lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
resource: 'users', resource: 'users',
marks: { [target.id]: 'deleted' }, marks: { [target.id]: 'deleted' },
commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id)), commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id))
}) })
setDeleteFor(null) setDeleteFor(null)
}} }}
@@ -191,7 +190,7 @@ export function UsersPage() {
lines: targets.map((t) => `− ${t.email}`), lines: targets.map((t) => `− ${t.email}`),
resource: 'users', resource: 'users',
marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])), marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])),
commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id))), commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id)))
}) })
setBulkDeleteIds(null) setBulkDeleteIds(null)
}} }}
+10 -9
View File
@@ -11,6 +11,7 @@ import {
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
KeyRoundIcon, KeyRoundIcon,
ShieldCheckIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LogOutIcon, LogOutIcon,
SearchIcon, SearchIcon,
@@ -89,11 +90,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>
@@ -271,7 +272,7 @@ export function PreviewSidebar() {
<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) => (
@@ -293,7 +294,7 @@ export function PreviewSidebar() {
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))} onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/> />
} }
className="cursor-pointer justify-between" className="cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
> >
{group} {group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden /> <ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
@@ -343,22 +344,22 @@ function MonitorPopover() {
variant="outline" variant="outline"
type="button" type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`} 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-3xl/header:w-8 @max-3xl/header:border-transparent @max-3xl/header:bg-transparent @max-3xl/header:px-0" 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]/header:w-8 @max-[760px]/header:border-transparent @max-[760px]/header:bg-transparent @max-[760px]/header:px-0"
/> />
} }
> >
<ActivityIcon className="text-warning size-4" aria-hidden /> <ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium @4xl/header:inline">Внимание</span> <span className="text-warning hidden text-sm font-medium @[1000px]/header:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums @6xl/header:inline-flex"> <span className="hidden items-center gap-1.5 text-sm tabular-nums @[1240px]/header:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden /> <span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors} {errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden /> <span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings} {warnings}
</span> </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 @3xl/header:inline-flex @6xl/header:hidden"> <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 @[760px]/header:inline-flex @[1240px]/header:hidden">
{bad.length} {bad.length}
</span> </span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 @3xl/header:hidden" aria-hidden /> <span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 @[760px]/header:hidden" aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2"> <PopoverContent align="end" className="flex w-[22rem] 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="flex items-start justify-between gap-2 px-1.5 pt-1.5">
+1 -1
View File
@@ -282,7 +282,7 @@ export function PreviewSectionGrid<T extends object>({
onRowClick={onRowClick} onRowClick={onRowClick}
getRowStatus={rowStatus} getRowStatus={rowStatus}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }} tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
> >
<Frame spacing="sm" className={cn('w-full min-w-0', className)}> <Frame spacing="sm" className={cn('w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2"> <FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">