fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса

Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit
от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись.
На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 03:18:06 +07:00
1 parent 60e0b886fc
commit 709dd93548
4 files changed
+224 -44

No files matched your search

@@ -0,0 +1,156 @@
import type { ReactNode } from 'react'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
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 { APP_SHORT } from './mock'
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 spacing="sm" className="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 TONE: Record<AuditSeverity, { color: string; label: string; tone: 'ok' | 'wn' | 'er' }> = {
info: { color: 'bg-success', label: 'Info', tone: 'ok' },
warning: { color: 'bg-warning', label: 'Warning', tone: 'wn' },
critical: { color: 'bg-destructive', label: 'Critical', tone: 'er' },
}
function when(iso: string) {
const d = new Date(iso)
const ageH = (Date.now() - d.getTime()) / 3_600_000
if (ageH < 24) return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(d)
if (ageH < 48) return 'вчера'
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(d)
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
entries,
action,
}: {
title: string
description: string
entries: AuditLogEntry[]
action?: ReactNode
}) {
return (
<Frame spacing="sm" className="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>
{entries.map((e) => {
const t = TONE[e.severity]
const source = e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app]
return (
<li key={e.id} 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">{when(e.created_at)}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', t.color)} />
<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={t.tone}>{t.label}</StatusBadge>
</span>
<b className="font-medium">{e.summary}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">
{e.actor_name ?? 'system'} · {source}
{e.ip ? ` · ${e.ip}` : ''}
</span>
</span>
</li>
)
})}
</ol>
</FramePanel>
</Frame>
)
}
@@ -17,7 +17,6 @@ import {
} 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'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame' import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile' import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
@@ -42,6 +41,7 @@ import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { PageHead, PreviewSheet, StatusBadge } from '../bits' import { PageHead, PreviewSheet, StatusBadge } from '../bits'
import { AppCards } from '../canvas-blocks'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { MOCK_SWITCHER } from '../mock' import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store' import { usePreviewStore } from '../preview-store'
@@ -341,18 +341,18 @@ export function AppLinksPage() {
</SettingRow> </SettingRow>
</FramePanel> </FramePanel>
</Frame> </Frame>
<QuickActionGrid <AppCards
autoFit
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),
meta: r.id,
icon: <Icon />, icon: <Icon />,
} }
})} })}
+49 -35
View File
@@ -1,11 +1,23 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon } from 'lucide-react' import {
CloudIcon,
FingerprintIcon,
GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
ServerIcon,
ShieldCheckIcon,
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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { PageHead } from '../bits' import { PageHead } from '../bits'
import { AppCards, type AppCardItem } from '../canvas-blocks'
import { kpi } from '../kpi'
const ICONS: Record<AppId, typeof CloudIcon> = { const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon, cfdm: CloudIcon,
@@ -23,59 +35,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>
} }
/> />
<KpiStatGrid
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: 'полный доступ' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0"> <Tabs defaultValue="all" className="min-w-0">
<TabsList> <TabsList>
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger> <TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger> <TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="all" className="flex flex-col gap-4"> <TabsContent value="all" className="min-w-0">
<QuickActionGrid <AppCards
autoFit
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
autoFit
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>
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-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 type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel' import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
import { ActivityFeed } from '../canvas-blocks'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
@@ -43,6 +43,7 @@ const VIEWS = [
export function AuditPage() { export function AuditPage() {
const [entry, setEntry] = useState<AuditLogEntry | null>(null) const [entry, setEntry] = useState<AuditLogEntry | 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 (
@@ -66,14 +67,23 @@ export function AuditPage() {
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }), kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }),
]} ]}
/> />
<Tabs defaultValue="feed" className="min-w-0"> <Tabs value={tab} onValueChange={setTab} className="min-w-0">
<TabsList> <TabsList>
<TabsTrigger value="feed">Лента</TabsTrigger> <TabsTrigger value="feed">Лента</TabsTrigger>
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger> <TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger>
<TabsTrigger value="retention">Ротация</TabsTrigger> <TabsTrigger value="retention">Ротация</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="feed" className="min-w-0"> <TabsContent value="feed" className="min-w-0">
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} /> <ActivityFeed
title="Лента изменений"
description="Последние события · точка отражает важность, плашка — уровень"
entries={[...MOCK_AUDIT].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6)}
action={
<Button variant="outline" size="sm" onClick={() => setTab('table')}>
Весь журнал
</Button>
}
/>
</TabsContent> </TabsContent>
<TabsContent value="table" className="min-w-0"> <TabsContent value="table" className="min-w-0">
<PreviewSectionGrid <PreviewSectionGrid