fix(design-preview): полоса KPI, баннеры и шапка точно как на досках Auth Portal
Полоса показателей — один Frame с линиями и плашками, колонки по порогам контейнера; баннер в одну строку с «Объяснить»; бренд «Единый вход · SSO», заголовки групп капсом; пороги монитора в шапке как на доске. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
afbc6fc366
commit
cd6516edd6
12 files changed
+165
-138
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits'
|
||||
|
||||
/**
|
||||
@@ -23,17 +24,20 @@ export function ExplainBanner({
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<>
|
||||
<SignalAlert
|
||||
tone={tone}
|
||||
title={title}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Объяснить
|
||||
</Button>
|
||||
}
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SignalAlert>
|
||||
<div className="min-w-0 flex-1 basis-80 text-pretty">
|
||||
<b className="font-semibold">{title}</b> {children}
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Объяснить
|
||||
</Button>
|
||||
</div>
|
||||
<PreviewSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
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',
|
||||
export type KpiItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
/** Плашка-подсказка рядом со значением. */
|
||||
hint: string
|
||||
tone: KpiTone
|
||||
}
|
||||
|
||||
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: иконка по тону, значение с пояснением, бейдж статуса. */
|
||||
/** Плитка KPI доски: подпись, значение (цветом при wn/er) и плашка-подсказка. */
|
||||
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),
|
||||
}
|
||||
opts: { icon?: unknown; tone?: KpiTone; badge: string; sub?: string },
|
||||
): KpiItem {
|
||||
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral' }
|
||||
}
|
||||
@@ -2,16 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChartColumnIcon,
|
||||
CloudIcon,
|
||||
EyeIcon,
|
||||
FingerprintIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
LayoutGridIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
RotateCcwIcon,
|
||||
ServerIcon,
|
||||
type LucideIcon,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
|
||||
@@ -24,11 +20,11 @@ import {
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectValue
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
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 { AppCards } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
@@ -50,7 +46,7 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
|
||||
server: { label: 'Сервер', Icon: ServerIcon },
|
||||
globe: { label: 'Сеть', Icon: NetworkIcon },
|
||||
dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon },
|
||||
chart: { label: 'График', Icon: ChartColumnIcon },
|
||||
chart: { label: 'График', Icon: ChartColumnIcon }
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
@@ -64,7 +60,7 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
|
||||
url: a.url,
|
||||
icon: a.icon,
|
||||
mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC',
|
||||
enabled: a.id !== 'mm',
|
||||
enabled: a.id !== 'mm'
|
||||
}))
|
||||
|
||||
export function AppLinksPage() {
|
||||
@@ -92,7 +88,7 @@ export function AppLinksPage() {
|
||||
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
|
||||
resource: 'app-links',
|
||||
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
|
||||
revert: () => setRows(before),
|
||||
revert: () => setRows(before)
|
||||
})
|
||||
},
|
||||
[addChange],
|
||||
@@ -115,7 +111,7 @@ export function AppLinksPage() {
|
||||
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
|
||||
resource: 'app-links',
|
||||
marks: { [item!.id]: 'dirty' },
|
||||
revert: () => setRows(before),
|
||||
revert: () => setRows(before)
|
||||
})
|
||||
},
|
||||
[addChange],
|
||||
@@ -140,7 +136,7 @@ export function AppLinksPage() {
|
||||
lines: ['− пользовательский порядок', '+ порядок каталога'],
|
||||
resource: 'app-links',
|
||||
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
|
||||
revert: () => setRows(before),
|
||||
revert: () => setRows(before)
|
||||
})
|
||||
}, [addChange])
|
||||
|
||||
@@ -167,7 +163,7 @@ export function AppLinksPage() {
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }),
|
||||
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }),
|
||||
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }),
|
||||
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }),
|
||||
kpi('n', 'Приложений', String(rows.length), { badge: 'в каталоге' }),
|
||||
kpi('sw', 'В переключателе', String(shown.length), { tone: 'ok', badge: 'видны всем' }),
|
||||
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), { badge: 'Technitium DNS' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="list" className="min-w-0">
|
||||
@@ -215,13 +211,13 @@ export function AppLinksPage() {
|
||||
onLabel: 'Показать в меню',
|
||||
offLabel: 'Скрыть',
|
||||
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) }}
|
||||
rowMenu={(r) => ({
|
||||
onOpen: () => setEditing(r),
|
||||
toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
|
||||
onToggle: () => setEnabled([r.id], !r.enabled),
|
||||
onToggle: () => setEnabled([r.id], !r.enabled)
|
||||
})}
|
||||
onReorder={dropOn}
|
||||
rowStatus={(r) => marks[r.id]}
|
||||
@@ -252,7 +248,7 @@ export function AppLinksPage() {
|
||||
badge: r.mode,
|
||||
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
|
||||
meta: r.id,
|
||||
icon: <Icon />,
|
||||
icon: <Icon />
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
CloudIcon,
|
||||
FingerprintIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
LayoutGridIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldIcon,
|
||||
ShieldIcon
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
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 { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { PageHead, PageTab, PageTabsList } from '../bits'
|
||||
@@ -26,7 +22,7 @@ const ICONS: Record<AppId, typeof CloudIcon> = {
|
||||
fw: ShieldIcon,
|
||||
dns: GlobeIcon,
|
||||
cdn: CloudIcon,
|
||||
mm: LayoutDashboardIcon,
|
||||
mm: LayoutDashboardIcon
|
||||
}
|
||||
|
||||
const NO_ACCESS: AppId[] = ['cdn']
|
||||
@@ -49,7 +45,7 @@ export function AppsPage() {
|
||||
onSelect: () =>
|
||||
open
|
||||
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
|
||||
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
|
||||
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,13 +60,13 @@ export function AppsPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: 'доступ есть', sub: `из ${APPS.length}` }),
|
||||
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'вход без пароля' }),
|
||||
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }),
|
||||
kpi('role', 'Роль', 'Админ', { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }),
|
||||
kpi('avail', 'Доступно приложений', String(allowed.length), { tone: 'ok', badge: `из ${APPS.length}` }),
|
||||
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), { badge: 'Technitium DNS' }),
|
||||
kpi('role', 'Роль', 'Админ', { badge: 'is_admin' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="all" className="min-w-0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { HistoryIcon, NetworkIcon, ShieldAlertIcon, Trash2Icon, UserCogIcon } from 'lucide-react'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
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 { Input } from '@authportal/ui/components/input'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
@@ -47,13 +47,13 @@ export function AuditPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }),
|
||||
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }),
|
||||
kpi('crit', 'Критичных', '3', { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }),
|
||||
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'портал +5 приложений', sub: 'ingest' }),
|
||||
kpi('all', 'Событий', '148', { badge: 'за 7 дней' }),
|
||||
kpi('acc', 'Изменений прав', '22', { tone: 'ok', badge: 'user.access_update' }),
|
||||
kpi('crit', 'Критичных', '3', { tone: 'er', badge: 'critical' }),
|
||||
kpi('src', 'Источников', '6', { badge: 'портал +5 приложений' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { ExplainBanner } from '../explain-banner'
|
||||
@@ -62,13 +62,13 @@ export function LoginsPage() {
|
||||
6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
|
||||
</ExplainBanner>
|
||||
<Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} />
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('ok', 'Успешные (24ч)', '38', { icon: LogInIcon, tone: 'ok', badge: 'входы и SSO', sub: 'без ошибок' }),
|
||||
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }),
|
||||
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }),
|
||||
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }),
|
||||
kpi('ok', 'Успешные (24ч)', '38', { tone: 'ok', badge: 'входы и SSO' }),
|
||||
kpi('fail', 'Неудачи (24ч)', '6', { tone: 'er', badge: 'неверный пароль' }),
|
||||
kpi('ip', 'Уникальные IP (24ч)', '9', { badge: '2 новых' }),
|
||||
kpi('sess', 'Активные сессии', String(sessions.length), { badge: '3 пользователя' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="hist" className="min-w-0">
|
||||
@@ -107,7 +107,7 @@ export function LoginsPage() {
|
||||
onToggle: () => {
|
||||
setSessions((s) => s.filter((x) => x.id !== r.id))
|
||||
toast.success(`Сессия ${r.user} завершена (макет)`)
|
||||
},
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { 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 {
|
||||
AlertDialog,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTitle
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
OIDC_CAPABILITIES,
|
||||
OIDC_ENDPOINTS,
|
||||
type OidcClientRow,
|
||||
type Tone,
|
||||
type Tone
|
||||
} from '../mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
|
||||
@@ -66,7 +66,7 @@ export function OidcPage() {
|
||||
secret: 'действует',
|
||||
created: '17.10.2026',
|
||||
enabled: values.enabled,
|
||||
stale: false,
|
||||
stale: false
|
||||
}
|
||||
setClients((c) => [...c, created])
|
||||
setCreateOpen(false)
|
||||
@@ -96,13 +96,13 @@ export function OidcPage() {
|
||||
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
|
||||
</ExplainBanner>
|
||||
) : null}
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }),
|
||||
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }),
|
||||
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }),
|
||||
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }),
|
||||
kpi('n', 'Клиентов', String(clients.length), { badge: 'зарегистрировано' }),
|
||||
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { tone: 'ok', badge: 'принимают вход' }),
|
||||
kpi('tok', 'Токенов за 24 ч', '41', { badge: 'authorization_code' }),
|
||||
kpi('key', 'Ключ подписи', 'RS256', { badge: 'ротация вручную' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="clients" className="min-w-0">
|
||||
@@ -139,7 +139,7 @@ export function OidcPage() {
|
||||
rowMenu={(r) => ({
|
||||
onOpen: () => setEditing(r),
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { KpiStrip } from '../kpi-strip'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
@@ -59,13 +59,13 @@ export function SecurityPage() {
|
||||
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
|
||||
</ExplainBanner>
|
||||
) : null}
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: keys.length > 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }),
|
||||
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }),
|
||||
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }),
|
||||
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }),
|
||||
kpi('keys', 'Passkeys', String(keys.length), { tone: 'ok', badge: 'активны' }),
|
||||
kpi('last', 'Последний вход', 'сегодня', { badge: 'MacBook Touch ID' }),
|
||||
kpi('pw', 'Пароль', 'Задан', { badge: 'запасной вход' }),
|
||||
kpi('sess', 'Сессий', '3', { badge: 'устройства' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="keys" className="min-w-0">
|
||||
@@ -90,7 +90,7 @@ export function SecurityPage() {
|
||||
onChange: (r, on) => {
|
||||
setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id]))
|
||||
toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)')
|
||||
},
|
||||
}
|
||||
}}
|
||||
rowMenu={(r) => ({
|
||||
onOpen: () => {
|
||||
@@ -99,7 +99,7 @@ export function SecurityPage() {
|
||||
},
|
||||
toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать',
|
||||
onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])),
|
||||
onDelete: () => setDeleting(r),
|
||||
onDelete: () => setDeleting(r)
|
||||
})}
|
||||
onRowClick={(r) => {
|
||||
setSelected(r)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||
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 { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { UserPlusIcon } from 'lucide-react'
|
||||
@@ -55,7 +54,7 @@ export function UsersPage() {
|
||||
last_login_ip: null,
|
||||
passkey_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
setUsers((prev) => [...prev, created])
|
||||
addChange({
|
||||
@@ -64,7 +63,7 @@ export function UsersPage() {
|
||||
lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`],
|
||||
resource: 'users',
|
||||
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)
|
||||
setAccessFor(created)
|
||||
@@ -85,7 +84,7 @@ export function UsersPage() {
|
||||
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
|
||||
`− disabled: ${String(!disabled)}`,
|
||||
`+ disabled: ${String(disabled)}`,
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
const admins = users.filter((u) => u.is_admin).length
|
||||
@@ -111,13 +110,13 @@ export function UsersPage() {
|
||||
>
|
||||
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
|
||||
</ExplainBanner>
|
||||
<KpiStatGrid
|
||||
<KpiStrip
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }),
|
||||
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }),
|
||||
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }),
|
||||
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }),
|
||||
kpi('total', 'Пользователей', String(users.length), { badge: 'в каталоге' }),
|
||||
kpi('admins', 'Администраторов', String(admins), { tone: 'wn', badge: 'is_admin' }),
|
||||
kpi('nopk', 'Без passkey', String(noPasskey), { tone: 'wn', badge: 'только пароль' }),
|
||||
kpi('off', 'Отключённых', String(disabled), { badge: 'вход закрыт' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="users" className="min-w-0">
|
||||
@@ -169,7 +168,7 @@ export function UsersPage() {
|
||||
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
|
||||
resource: 'users',
|
||||
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)
|
||||
}}
|
||||
@@ -191,7 +190,7 @@ export function UsersPage() {
|
||||
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))),
|
||||
commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id)))
|
||||
})
|
||||
setBulkDeleteIds(null)
|
||||
}}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
KeyRoundIcon,
|
||||
ShieldCheckIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
SearchIcon,
|
||||
@@ -89,11 +90,11 @@ function PreviewAppSwitcher() {
|
||||
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>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<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>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -271,7 +272,7 @@ export function PreviewSidebar() {
|
||||
<SidebarContent>
|
||||
{pinned.length > 0 ? (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pinned.map((item) => (
|
||||
@@ -293,7 +294,7 @@ export function PreviewSidebar() {
|
||||
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}
|
||||
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||
@@ -343,22 +344,22 @@ function MonitorPopover() {
|
||||
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-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 />
|
||||
<span className="text-warning hidden text-sm font-medium @4xl/header:inline">Внимание</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums @6xl/header:inline-flex">
|
||||
<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 @[1240px]/header: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 @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}
|
||||
</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>
|
||||
<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">
|
||||
|
||||
@@ -282,7 +282,7 @@ export function PreviewSectionGrid<T extends object>({
|
||||
onRowClick={onRowClick}
|
||||
getRowStatus={rowStatus}
|
||||
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)}>
|
||||
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
|
||||
|
||||
Reference in new issue
Block a user