fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса
Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись. На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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'
|
||||
import { toast } from 'sonner'
|
||||
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'
|
||||
@@ -42,6 +41,7 @@ import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
|
||||
import { AppCards } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
import { MOCK_SWITCHER } from '../mock'
|
||||
import { usePreviewStore } from '../preview-store'
|
||||
@@ -341,18 +341,18 @@ export function AppLinksPage() {
|
||||
</SettingRow>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<QuickActionGrid
|
||||
autoFit
|
||||
<AppCards
|
||||
title="Предпросмотр меню"
|
||||
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
|
||||
actions={shown.slice(0, 4).map((r) => {
|
||||
items={shown.slice(0, 4).map((r) => {
|
||||
const { Icon } = ICONS[r.icon]
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.name,
|
||||
description: r.url.replace('https://', ''),
|
||||
badgeLabel: r.mode,
|
||||
desc: r.url.replace('https://', ''),
|
||||
badge: r.mode,
|
||||
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
|
||||
meta: r.id,
|
||||
icon: <Icon />,
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { PageHead } from '../bits'
|
||||
import { AppCards, type AppCardItem } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
|
||||
const ICONS: Record<AppId, typeof CloudIcon> = {
|
||||
cfdm: CloudIcon,
|
||||
@@ -23,59 +35,61 @@ export function AppsPage() {
|
||||
const allowed = 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 (
|
||||
<>
|
||||
<PageHead
|
||||
title="Приложения"
|
||||
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
|
||||
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all" className="flex flex-col gap-4">
|
||||
<QuickActionGrid
|
||||
autoFit
|
||||
<TabsContent value="all" className="min-w-0">
|
||||
<AppCards
|
||||
title="Мои приложения"
|
||||
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами"
|
||||
actions={allowed.map((app) => {
|
||||
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()} (макет)` }),
|
||||
}
|
||||
})}
|
||||
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
|
||||
items={allowed.map((a) => toCard(a, true))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="no">
|
||||
<QuickActionGrid
|
||||
autoFit
|
||||
<TabsContent value="no" className="min-w-0">
|
||||
<AppCards
|
||||
title="Недоступные приложения"
|
||||
description="Права не выданы · запросите у администратора"
|
||||
actions={denied.map((app) => {
|
||||
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: 'Запросите права у администратора.' }),
|
||||
}
|
||||
})}
|
||||
items={denied.map((a) => toCard(a, false))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
||||
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
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 { ActivityFeed } from '../canvas-blocks'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
@@ -43,6 +43,7 @@ const VIEWS = [
|
||||
export function AuditPage() {
|
||||
const [entry, setEntry] = useState<AuditLogEntry | null>(null)
|
||||
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||
const [tab, setTab] = useState('feed')
|
||||
const [retention, setRetention] = useState(90)
|
||||
|
||||
return (
|
||||
@@ -66,14 +67,23 @@ export function AuditPage() {
|
||||
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>
|
||||
<TabsTrigger value="feed">Лента</TabsTrigger>
|
||||
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger>
|
||||
<TabsTrigger value="retention">Ротация</TabsTrigger>
|
||||
</TabsList>
|
||||
<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 value="table" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
|
||||
Reference in new issue
Block a user