diff --git a/apps/web/src/design-preview/canvas-blocks.tsx b/apps/web/src/design-preview/canvas-blocks.tsx new file mode 100644 index 0000000..63c2adb --- /dev/null +++ b/apps/web/src/design-preview/canvas-blocks.tsx @@ -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 ( + + +
+ {title} + {description} +
+ {action} +
+ + + + + ) +} + +const TONE: Record = { + 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 ( + + +
+ {title} + {description} +
+ {action} +
+ +
    + {entries.map((e) => { + const t = TONE[e.severity] + const source = e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app] + return ( +
  1. + {when(e.created_at)} + + + + + + + {t.label} + + {e.summary} + + {e.actor_name ?? 'system'} · {source} + {e.ip ? ` · ${e.ip}` : ''} + + +
  2. + ) + })} +
+
+ + ) +} diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx index f1da6e6..9f77fa5 100644 --- a/apps/web/src/design-preview/pages/app-links-page.tsx +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -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() { - { + 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: , } })} diff --git a/apps/web/src/design-preview/pages/apps-page.tsx b/apps/web/src/design-preview/pages/apps-page.tsx index 6e8278a..129677a 100644 --- a/apps/web/src/design-preview/pages/apps-page.tsx +++ b/apps/web/src/design-preview/pages/apps-page.tsx @@ -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 = { 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: , + onSelect: () => + open + ? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` }) + : toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }), + } + } + return ( <> } nativeButton={false}> + } /> + 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: 'полный доступ' }), + ]} + /> Доступные · {allowed.length} Без доступа · {denied.length} - - + { - 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: , - onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }), - } - })} + description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами" + items={allowed.map((a) => toCard(a, true))} /> - - + { - 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: , - onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }), - } - })} + items={denied.map((a) => toCard(a, false))} /> diff --git a/apps/web/src/design-preview/pages/audit-page.tsx b/apps/web/src/design-preview/pages/audit-page.tsx index 6632502..adb2002 100644 --- a/apps/web/src/design-preview/pages/audit-page.tsx +++ b/apps/web/src/design-preview/pages/audit-page.tsx @@ -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(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 приложений' }), ]} /> - + Лента Таблица · {MOCK_AUDIT.length} Ротация - + b.created_at.localeCompare(a.created_at)).slice(0, 6)} + action={ + + } + />