feat(design-preview): перенос досок Auth Portal 1 к 1 — таблицы, вкладки, формы, права

Таблицы по анатомии доски: «Таблица/Карточки», поиск, «Колонки», виды линией со счётчиками, панель
массовых действий, переключатель и меню «⋮» в строке. Вкладки страниц — линия с мелким счётчиком.
Строки настроек (пароль, ротация журнала, меню), «Объяснить» в баннерах с панелью разбора,
палитра с ассистентом и OIDC-клиентами, сворачиваемые группы меню, плашка «Обновлено · Live»,
данные досок «Изменения» и «Доступ по приложениям», права доступа вкладками.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 03:35:45 +07:00
1 parent 709dd93548
commit afbc6fc366
16 files changed
+991 -818

No files matched your search

+92 -125
View File
@@ -1,20 +1,26 @@
import { useState } from 'react'
import { ChevronRightIcon, ScrollTextIcon } from 'lucide-react'
import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared' import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
import { Badge } from '@/components/reui/badge' import {
import { Button } from '@authportal/ui/components/button' Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { CodeBlock, SignalAlert } from './bits' import { CodeBlock, SignalAlert } from './bits'
import { LEVEL_LABEL, grantCount, levelOf, withLevel, type AccessValue, type Level } from './access-model' import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
import { USER_JOURNAL } from './mock' import { APP_SHORT, USER_JOURNAL } from './mock'
type Pane = AppId | 'journal' const LEVEL_NAME: Record<Level, string> = { none: 'Нет доступа', read: 'Чтение', write: 'Запись', admin: 'Админ' }
/** Приложения, у которых на доске есть вкладка с разделами. */
const PERM_TABS: AppId[] = ['cfdm', 'vps', 'bgp', 'fw', 'mm']
/** /**
* Редактор прав: слева приложения (переключатель доступа и счётчик выданных разделов), * Редактор прав по доске «Права доступа»: вкладки «Приложения», по приложению с разделами и «Журнал».
* справа разделы выбранного приложения с уровнем «Нет / Чтение / Запись / Админ». * Раздел — строка с названием, подсказкой и уровнем «Нет доступа / Чтение / Запись / Админ».
*/ */
export function AccessEditor({ export function AccessEditor({
user, user,
@@ -25,137 +31,98 @@ export function AccessEditor({
value: AccessValue value: AccessValue
onChange: (next: AccessValue) => void onChange: (next: AccessValue) => void
}) { }) {
const [pane, setPane] = useState<Pane>(() => value.apps[0] ?? 'cfdm')
const catalog = pane === 'journal' ? null : PERMISSION_CATALOG.find((c) => c.appId === pane)
const app = pane === 'journal' ? null : APPS.find((a) => a.id === pane)
const appOn = pane !== 'journal' && value.apps.includes(pane)
function setLevel(appId: AppId, sectionId: string, actions: readonly ('read' | 'write' | 'admin')[], level: Level) {
onChange({ ...value, permissions: withLevel(value.permissions, appId, sectionId, actions, level) })
}
function setAll(level: Level) {
if (!catalog) return
let perms = value.permissions
for (const s of catalog.sections) {
const allowed: Level[] = ['none', ...s.actions]
const target: Level = allowed.includes(level) ? level : 'none'
perms = withLevel(perms, catalog.appId, s.id, s.actions, target)
}
onChange({ ...value, permissions: perms })
}
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-4"> <div className="flex min-h-0 flex-1 flex-col gap-3">
{user.is_admin ? ( {user.is_admin ? (
<SignalAlert tone="in" title="Администратор"> <SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже. Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert> </SignalAlert>
) : null} ) : null}
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[15.5rem_minmax(0,1fr)]"> <Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<nav aria-label="Приложения" className="flex flex-col gap-1.5"> <TabsList variant="line" className="h-10! w-full justify-start gap-5 overflow-x-auto border-b">
{APPS.map((a) => { <TabsTrigger value="apps" className="px-1 text-sm after:-bottom-px!">
const { granted, total } = grantCount(value.permissions, a.id) Приложения
const on = value.apps.includes(a.id) </TabsTrigger>
const active = pane === a.id {PERM_TABS.map((id) => (
return ( <TabsTrigger key={id} value={id} className="px-1 text-sm after:-bottom-px!">
<div {id === 'vps' ? 'VPS Tracker' : APP_SHORT[id]}
key={a.id} </TabsTrigger>
className={cn( ))}
'flex items-center gap-2 rounded-lg border px-2.5 py-2 transition-colors', <TabsTrigger value="journal" className="px-1 text-sm after:-bottom-px!">
active ? 'bg-muted border-border' : 'border-transparent hover:bg-muted/50',
)}
>
<Switch
size="sm"
checked={on}
onCheckedChange={(next) =>
onChange({ ...value, apps: next ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
}
aria-label={`${a.title}: доступ к приложению`}
/>
<button
type="button"
aria-current={active ? 'true' : undefined}
onClick={() => setPane(a.id)}
className="focus-visible:ring-ring flex min-w-0 flex-1 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-2"
>
<span className={cn('min-w-0 flex-1 truncate text-sm font-medium', !on && 'text-muted-foreground')}>
{a.title}
</span>
<Badge variant={on && granted > 0 ? 'info-light' : 'outline'} size="sm" className="shrink-0 tabular-nums">
{granted}/{total}
</Badge>
</button>
</div>
)
})}
<button
type="button"
aria-current={pane === 'journal' ? 'true' : undefined}
onClick={() => setPane('journal')}
className={cn(
'focus-visible:ring-ring mt-1 flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm font-medium outline-none focus-visible:ring-2',
pane === 'journal' ? 'bg-muted border-border' : 'text-muted-foreground hover:bg-muted/50 border-transparent',
)}
>
<ScrollTextIcon className="size-4" aria-hidden />
Журнал Журнал
<ChevronRightIcon className="ml-auto size-4" aria-hidden /> </TabsTrigger>
</button> </TabsList>
</nav>
<section className="flex min-w-0 flex-col gap-3" aria-label={app?.title ?? 'Журнал'}> <TabsContent value="apps" className="min-h-0 overflow-y-auto">
{pane === 'journal' ? ( <ul className="flex flex-col divide-y rounded-lg border">
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock> {APPS.map((a) => (
) : catalog && app ? ( <li key={a.id} className="flex items-center justify-between gap-3 px-3.5 py-3">
<> <label htmlFor={`acc-${a.id}`} className="flex min-w-0 flex-col">
<div className="flex flex-wrap items-start justify-between gap-2"> <span className="text-sm font-medium">{a.title}</span>
<div className="flex min-w-0 flex-col gap-0.5"> <span className="text-muted-foreground text-xs">{a.description}</span>
<h3 className="text-sm font-semibold">{app.title}</h3> </label>
<p className="text-muted-foreground text-xs">{app.description}</p> <Switch
</div> id={`acc-${a.id}`}
<div className="flex shrink-0 gap-1.5"> checked={value.apps.includes(a.id)}
<Button type="button" size="xs" variant="outline" onClick={() => setAll('read')}> onCheckedChange={(on) =>
Чтение везде onChange({ ...value, apps: on ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
</Button> }
<Button type="button" size="xs" variant="outline" onClick={() => setAll('none')}> />
Сбросить </li>
</Button> ))}
</div> </ul>
</div> </TabsContent>
{!appOn ? (
<p className="text-muted-foreground bg-muted/40 rounded-md border px-3 py-2 text-xs"> {PERM_TABS.map((appId) => {
Приложение выключено слева — права разделов не действуют, пока оно не включено. const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
</p> if (!catalog) return null
) : null} return (
<ul className={cn('flex flex-col divide-y rounded-lg border', !appOn && 'opacity-60')}> <TabsContent key={appId} value={appId} className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{catalog.sections.map((section) => { {catalog.sections.map((section) => {
const level = levelOf(value.permissions, catalog.appId, section.id, section.actions) const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions] const options: Level[] = ['none', ...section.actions]
return ( return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5"> <li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3.5 py-3">
<div className="flex min-w-0 flex-1 basis-40 flex-col"> <div className="flex min-w-0 flex-1 basis-44 flex-col">
<span className="text-sm font-medium">{section.title}</span> <span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span> <span className="text-muted-foreground text-xs">
{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}
</span>
</div> </div>
<Tabs value={level} onValueChange={(v) => setLevel(catalog.appId, section.id, section.actions, v as Level)}> <Select
<TabsList aria-label={`${section.title}: уровень доступа`}> value={level}
{options.map((o) => ( onValueChange={(v) =>
<TabsTrigger key={o} value={o}> v && onChange({ ...value, permissions: withLevel(value.permissions, appId, section.id, section.actions, v as Level) })
{LEVEL_LABEL[o]} }
</TabsTrigger> items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))}
))} >
</TabsList> <SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
</Tabs> <SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_NAME[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</li> </li>
) )
})} })}
</ul> </ul>
</> </TabsContent>
) : null} )
</section> })}
</div>
<TabsContent value="journal" className="flex min-h-0 flex-col gap-3 overflow-y-auto">
<p className="text-muted-foreground text-sm">Последние события пользователя из журнала портала.</p>
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
</div> </div>
) )
} }
+47 -76
View File
@@ -1,82 +1,53 @@
import { useMemo } from 'react' import { StatusBadge } from './bits'
import { LockIcon } from 'lucide-react' import type { Tone } from './mock'
import { APPS, type AdminUser, type AppId } from '@authportal/shared'
import { Switch } from '@authportal/ui/components/switch'
import { PendingChip, SignalAlert } from './bits'
import { APP_SHORT } from './mock'
import type { RowMark } from './preview-store'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
/** type MatrixRow = {
* Матрица доступа (solution-users-3): пользователи в строках, приложения в колонках, id: string
* переключатели в ячейках. Администраторам доступ выдан ко всем приложениям — их ячейки заблокированы. name: string
*/ sub: string
export function AccessMatrix({ tone: Tone
users, status: string
marks, total: string
onToggle, read: string
}: { write: string
users: AdminUser[] admin: string
marks: Record<string, RowMark> }
onToggle: (user: AdminUser, appId: AppId, on: boolean) => void
}) {
const columns = useMemo<GridColumn<AdminUser>[]>(() => {
const granted = (id: AppId) => users.filter((u) => u.is_admin || u.apps.includes(id)).length
return [
{
id: 'user',
title: 'Пользователь',
size: 260,
sortValue: (u) => u.email,
cell: (u) => (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<TwoLineCell dot={u.disabled ? 'mu' : 'ok'} title={u.email} sub={u.is_admin ? 'Админ · все приложения' : `${u.apps.length} из ${APPS.length}`} />
{marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
</div>
),
},
...APPS.map<GridColumn<AdminUser>>((a) => ({
id: a.id,
title: `${APP_SHORT[a.id]} · ${granted(a.id)}`,
size: 128,
cell: (u) =>
u.is_admin ? (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs" title="Администратор: доступ ко всем приложениям">
<Switch size="sm" checked disabled aria-label={`${u.email}: ${a.title} (заблокировано)`} />
<LockIcon className="size-3" aria-hidden />
</span>
) : (
<Switch
size="sm"
checked={u.apps.includes(a.id)}
onCheckedChange={(on) => onToggle(u, a.id, on)}
aria-label={`${u.email}: ${a.title}`}
/>
),
})),
]
}, [users, marks, onToggle])
/** Данные доски «Доступ по приложениям»: сколько пользователей имеет доступ и с каким уровнем. */
const ROWS: MatrixRow[] = [
{ id: 'cfdm', name: 'Cloudflare Domain Manager', sub: 'cfdm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '2', admin: '2' },
{ id: 'vps', name: 'VPS Tracker', sub: 'vps · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'bgp', name: 'EvoBGP', sub: 'bgp · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'fw', name: 'EvoFirewall', sub: 'fw · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'dns', name: 'Technitium DNS', sub: 'dns · OIDC', tone: 'ok', status: 'Выдан', total: '2', read: '0', write: '1', admin: '1' },
{ id: 'cdn', name: 'CDN Manager', sub: 'cdn · JWT', tone: 'wn', status: 'Мало админов', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'mm', name: 'MikrotikManager', sub: 'mm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
]
const mono = (v: string) => <span className="font-mono text-[12.5px]">{v}</span>
const COLUMNS: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.sub} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, cell: (r) => mono(r.total) },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => mono(r.read) },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => mono(r.write) },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => mono(r.admin) },
]
/** Матрица доступа по доске: сколько пользователей имеет доступ к каждому приложению и с каким уровнем. */
export function AccessMatrix() {
return ( return (
<div className="flex min-w-0 flex-col gap-3"> <PreviewSectionGrid
<SignalAlert tone="in" title="Администраторы видят все приложения"> title="Матрица доступа"
Их ячейки заблокированы. Тонкую настройку разделов откройте в правах пользователя. description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
</SignalAlert> rows={ROWS}
<PreviewSectionGrid columns={COLUMNS}
title="Матрица доступа" getRowId={(r) => r.id}
description="В заголовке колонки — сколько пользователей имеют доступ к приложению" views={[{ label: 'Все', match: () => true }]}
rows={users} searchText={(r) => `${r.name} ${r.id}`}
columns={columns} bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }}
getRowId={(u) => u.id} />
rowStatus={(u) => marks[u.id]}
searchText={(u) => u.email}
searchPlaceholder="Email"
views={[
{ label: 'Все', match: () => true },
{ label: 'Пользователи', match: (u) => !u.is_admin },
{ label: 'Админы', match: (u) => u.is_admin },
]}
/>
</div>
) )
} }
+65 -6
View File
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, CopyIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react' import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
@@ -343,19 +343,19 @@ export function CopyList({
key={item.label} key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0" className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
> >
<code className="min-w-0 flex-1 basis-64 font-mono text-[13px] break-all">{item.value}</code> <code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground basis-40 text-xs">{item.label}</span> <span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button <Button
type="button" type="button"
size="icon-sm" size="sm"
variant="ghost" variant="outline"
aria-label={`Скопировать: ${item.label}`} aria-label={`Скопировать: ${item.label}`}
onClick={() => { onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined) void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`) toast.success(`${item.label} скопирован`)
}} }}
> >
<CopyIcon aria-hidden /> Копировать
</Button> </Button>
</li> </li>
))} ))}
@@ -421,3 +421,62 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
</Badge> </Badge>
) )
} }
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-6 overflow-x-auto border-b">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="px-1 text-sm after:-bottom-px!">
{label}
{count !== undefined ? <sup className="text-muted-foreground ml-1 text-[10px] font-medium tabular-nums">{count}</sup> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: 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>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
+24 -38
View File
@@ -1,10 +1,9 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
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 { cn } from '@authportal/ui/lib/utils' import { cn } from '@authportal/ui/lib/utils'
import { APP_SHORT } from './mock' import type { Tone } from './mock'
import { StatusBadge } from './bits' import { StatusBadge } from './bits'
export type AppCardItem = { export type AppCardItem = {
@@ -86,18 +85,12 @@ export function AppCards({
) )
} }
const TONE: Record<AuditSeverity, { color: string; label: string; tone: 'ok' | 'wn' | 'er' }> = { const DOT: Record<Tone, string> = {
info: { color: 'bg-success', label: 'Info', tone: 'ok' }, ok: 'bg-success',
warning: { color: 'bg-warning', label: 'Warning', tone: 'wn' }, wn: 'bg-warning',
critical: { color: 'bg-destructive', label: 'Critical', tone: 'er' }, er: 'bg-destructive',
} mu: 'bg-muted-foreground',
in: 'bg-chart-4',
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)
} }
/** /**
@@ -107,12 +100,12 @@ function when(iso: string) {
export function ActivityFeed({ export function ActivityFeed({
title, title,
description, description,
entries, items,
action, action,
}: { }: {
title: string title: string
description: string description: string
entries: AuditLogEntry[] items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode action?: ReactNode
}) { }) {
return ( return (
@@ -126,29 +119,22 @@ export function ActivityFeed({
</FrameHeader> </FrameHeader>
<FramePanel className="py-1.5"> <FramePanel className="py-1.5">
<ol> <ol>
{entries.map((e) => { {items.map((e) => (
const t = TONE[e.severity] <li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
const source = e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app] <span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
return ( <span aria-hidden className="flex h-full flex-col items-center">
<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]"> <i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{when(e.created_at)}</span> <u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
<span aria-hidden className="flex h-full flex-col items-center"> </span>
<i className={cn('mt-1 size-[9px] rounded-full', t.color)} /> <span className="min-w-0">
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" /> <span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span> </span>
<span className="min-w-0"> <b className="font-medium">{e.title}</b>
<span className="mr-2 inline-block align-middle"> <span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
<StatusBadge tone={t.tone}>{t.label}</StatusBadge> </span>
</span> </li>
<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> </ol>
</FramePanel> </FramePanel>
</Frame> </Frame>
@@ -0,0 +1,79 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@authportal/ui/components/button'
import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
*/
export function ExplainBanner({
tone,
title,
children,
text,
}: {
tone: 'er' | 'wn'
title: string
/** Текст для панели разбора (то же, что в баннере). */
text: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
return (
<>
<SignalAlert
tone={tone}
title={title}
action={
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
Объяснить
</Button>
}
>
{children}
</SignalAlert>
<PreviewSheet
open={open}
onOpenChange={setOpen}
title="Ассистент · разбор"
description="Черновик: проверьте перед применением"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Закрыть
</Button>
<Button
onClick={() => {
setOpen(false)
void navigate({ to: '/design-preview/logins' })
}}
>
Открыть журнал входов
</Button>
</>
}
>
<SignalAlert tone="in" title="Что случилось">
{title} {text}
</SignalAlert>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Вероятные причины</span>
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone="er">Подбор пароля</StatusBadge>
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
<StatusBadge tone="mu">Смена IP</StatusBadge>
</div>
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
</div>
<CodeBlock label="Что проверить">
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/sessions?user=ops@shnt.top'}
</CodeBlock>
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
</SignalAlert>
</PreviewSheet>
</>
)
}
+45 -1
View File
@@ -151,7 +151,7 @@ export const MOCK_USERS: AdminUser[] = [
'ops@shnt.top', 'ops@shnt.top',
'Operations', 'Operations',
false, false,
['bgp', 'fw', 'mm'], ['bgp', 'fw', 'mm', 'dns'],
['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'], ['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'],
{ {
created_at: '2026-03-14T08:00:00.000Z', created_at: '2026-03-14T08:00:00.000Z',
@@ -455,3 +455,47 @@ export const USER_META: Record<string, { sub: string; activity: string }> = {
'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' }, 'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' },
'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' }, 'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' },
} }
/** Источник и свежесть данных по разделам — для панели «Источники и свежесть данных» в шапке. */
export const FRESH_BY_PAGE: Record<string, { badge: string; text: string }> = {
apps: { badge: 'Портал', text: 'Получено 14:02:11 · сессия действует до 22:02 · права из базы портала, не из токена' },
account: { badge: 'WebAuthn', text: 'Получено 14:02:11 · браузер поддерживает passkeys · домен auth.shnt.top' },
users: { badge: 'Портал', text: 'Получено 14:02:11 · GET /api/v1/admin/users · 7 учётных записей' },
logins: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=logins · хранение 90 дней' },
audit: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=changes · ingest от 5 приложений' },
oidc: { badge: 'OIDC', text: 'Получено 14:02:11 · discovery и JWKS доступны · подпись RS256' },
'app-links': { badge: 'Портал', text: 'Получено 14:02:11 · portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
}
export type AuditRow = {
id: string
action: string
summary: string
tone: Tone
label: string
source: string
who: string
ip: string
when: string
}
/** Таблица журнала с доски «Изменения» (значения те же, что на доске). */
export const AUDIT_ROWS: AuditRow[] = [
{ id: 'e1', action: 'user.update', summary: 'Отключён former@shnt.top', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '14:02' },
{ id: 'e2', action: 'user.access_update', summary: 'ops@shnt.top · +EvoFirewall:agents:write', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '13:50' },
{ id: 'e3', action: 'fw.policy_apply', summary: 'db-01 · поколение 128 · ошибка nft', tone: 'er', label: 'Critical', source: 'EvoFirewall', who: 'system', ip: '—', when: '12:31' },
{ id: 'e4', action: 'oidc.client_update', summary: 'Technitium DNS · новый секрет', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '09:12' },
{ id: 'e5', action: 'app_switcher.update', summary: '+CDN Manager в меню приложений', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: 'вчера 18:20' },
{ id: 'e6', action: 'mm.server_delete', summary: 'Удалён сервер old-edge', tone: 'wn', label: 'Warning', source: 'MikrotikManager', who: 'denis', ip: '198.51.100.4', when: 'вчера 15:02' },
{ id: 'e7', action: 'user.create', summary: 'Создан pavel@shnt.top', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '19.06' },
{ id: 'e8', action: 'audit.purge', summary: 'Очищено 212 записей старше 90 дн.', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '01.10' },
]
/** Лента с доски «Изменения». */
export const AUDIT_FEED: Array<{ t: string; title: string; text: string; label: string; tone: Tone }> = [
{ t: '14:02', title: 'Отключён пользователь former@shnt.top', text: 'admin · Auth Portal · 203.0.113.10', label: 'Warning', tone: 'wn' },
{ t: '13:50', title: 'Права обновлены: ops@shnt.top', text: '+EvoFirewall:agents:write · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: '12:31', title: 'Применена политика firewall (поколение 128)', text: 'system · EvoFirewall · db-01 — ошибка применения', label: 'Critical', tone: 'er' },
{ t: '09:12', title: 'Обновлён OIDC-клиент Technitium DNS', text: 'новый client_secret · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: 'вчера', title: 'Изменён список приложений в переключателе', text: '+CDN Manager · admin · Auth Portal', label: 'Info', tone: 'in' },
]
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ChartColumnIcon, ChartColumnIcon,
CloudIcon, CloudIcon,
@@ -7,9 +7,7 @@ import {
KeyRoundIcon, KeyRoundIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LayoutGridIcon, LayoutGridIcon,
MoreHorizontalIcon,
NetworkIcon, NetworkIcon,
PencilIcon,
PlusIcon, PlusIcon,
RotateCcwIcon, RotateCcwIcon,
ServerIcon, ServerIcon,
@@ -17,16 +15,7 @@ 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 { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
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'
import { import {
@@ -38,9 +27,9 @@ import {
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, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } 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, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { AppCards } from '../canvas-blocks' import { AppCards } from '../canvas-blocks'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { MOCK_SWITCHER } from '../mock' import { MOCK_SWITCHER } from '../mock'
@@ -78,19 +67,6 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
enabled: a.id !== 'mm', enabled: a.id !== 'mm',
})) }))
/** Строка настройки в Frame: слева название и описание, справа элемент управления (settings-10). */
function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
export function AppLinksPage() { export function AppLinksPage() {
const { addChange, marksFor } = usePreviewStore() const { addChange, marksFor } = usePreviewStore()
const marks = marksFor('app-links') const marks = marksFor('app-links')
@@ -170,77 +146,13 @@ export function AppLinksPage() {
const columns = useMemo<GridColumn<LinkRow>[]>( const columns = useMemo<GridColumn<LinkRow>[]>(
() => [ () => [
{ { id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
id: 'order', { id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
title: '№', { id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.icon}</span> },
size: 52, { id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
cell: (r) => ( { id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
<span className="text-muted-foreground text-sm tabular-nums">
{rows.findIndex((x) => x.id === r.id) + 1}
</span>
),
},
{
id: 'name',
title: 'Приложение',
size: 280,
cell: (r) => {
const { Icon } = ICONS[r.icon]
return (
<div className="flex min-w-0 items-center gap-3">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
<Icon />
</IconTile>
<TwoLineCell title={r.name} sub={r.id} />
</div>
)
},
},
{
id: 'menu',
title: 'В меню',
size: 170,
cell: (r) => (
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
<Switch
size="sm"
checked={r.enabled}
onCheckedChange={(on) => setEnabled([r.id], on)}
aria-label={`${r.name}: показывать в меню`}
/>
<StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge>
</div>
),
},
{ id: 'url', title: 'URL', size: 260, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.url}</code> },
{ id: 'mode', title: 'Вход', size: 100, cell: (r) => <StatusBadge tone={r.mode === 'OIDC' ? 'wn' : 'in'}>{r.mode}</StatusBadge> },
{
id: 'act',
title: '',
size: 56,
cell: (r) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${r.name}`} />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={() => setEditing(r)}>
<PencilIcon aria-hidden />
Настроить
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => moveTo(r.id, () => 0)}>В начало</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i - 1)}>Выше</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i + 1)}>Ниже</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (_i, n) => n - 1)}>В конец</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
], ],
[rows, setEnabled, moveTo], [],
) )
const shown = rows.filter((r) => r.enabled) const shown = rows.filter((r) => r.enabled)
@@ -274,18 +186,17 @@ export function AppLinksPage() {
]} ]}
/> />
<Tabs defaultValue="list" className="min-w-0"> <Tabs defaultValue="list" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="list">Приложения · {rows.length}</TabsTrigger> <PageTab value="list" label="Приложения" count={rows.length} />
<TabsTrigger value="menu">Меню</TabsTrigger> <PageTab value="menu" label="Меню" />
</TabsList> </PageTabsList>
<TabsContent value="list" className="min-w-0"> <TabsContent value="list" className="min-w-0">
<PreviewSectionGrid <PreviewSectionGrid
title="Ссылки сервисов" title="Ссылки сервисов"
description="Перетащите строку за ручку: порядок строк = порядок в меню · изменения применяются после сохранения" description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
rows={rows} rows={rows}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
selectable
pageSize={10} pageSize={10}
searchText={(r) => `${r.name} ${r.id} ${r.url}`} searchText={(r) => `${r.name} ${r.id} ${r.url}`}
searchPlaceholder="Название или адрес" searchPlaceholder="Название или адрес"
@@ -294,53 +205,41 @@ export function AppLinksPage() {
{ label: 'В меню', match: (r) => r.enabled }, { label: 'В меню', match: (r) => r.enabled },
{ label: 'Скрытые', match: (r) => !r.enabled }, { label: 'Скрытые', match: (r) => !r.enabled },
]} ]}
toolbar={ tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}> <Button type="button" size="sm" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden /> <RotateCcwIcon aria-hidden />
Сбросить порядок Сбросить порядок
</Button> </Button>
} }
bulkActions={(ids, clear) => ( bulk={{
<> onLabel: 'Показать в меню',
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], true); clear() }}> offLabel: 'Скрыть',
Показать в меню onRun: (ids) => setEnabled(ids as AppId[], true),
</Button> offRun: (ids) => setEnabled(ids as AppId[], false),
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], false); clear() }}> }}
Скрыть rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
</Button> rowMenu={(r) => ({
</> onOpen: () => setEditing(r),
)} toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
onToggle: () => setEnabled([r.id], !r.enabled),
})}
onReorder={dropOn} onReorder={dropOn}
rowStatus={(r) => marks[r.id]} rowStatus={(r) => marks[r.id]}
onRowClick={setEditing} onRowClick={setEditing}
/> />
</TabsContent> </TabsContent>
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4"> <TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
<Frame spacing="sm" className="w-full"> <FormFrame title="Переключатель приложений" description="Как выглядит меню в шапке каждого приложения" badge="Опубликовано">
<FrameHeader className="flex-row items-center justify-between gap-3"> <SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<div className="flex flex-col gap-0.5"> <Input value={menuLabel} onChange={(e) => setMenuLabel(e.target.value)} aria-label="Заголовок меню" className="w-56 max-w-full" />
<FrameTitle>Переключатель приложений</FrameTitle> </SettingRow>
<FrameDescription>Как выглядит меню в шапке каждого приложения</FrameDescription> <SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
</div> <Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
<StatusBadge tone="ok">Опубликовано</StatusBadge> </SettingRow>
</FrameHeader> <SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<FramePanel className="p-0"> <Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
<SettingRow title="Заголовок меню" description="Показывается над списком приложений"> </SettingRow>
<Input </FormFrame>
value={menuLabel}
onChange={(e) => setMenuLabel(e.target.value)}
aria-label="Заголовок меню"
className="w-56 max-w-full"
/>
</SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
</SettingRow>
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow>
</FramePanel>
</Frame>
<AppCards <AppCards
title="Предпросмотр меню" title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`} description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
@@ -14,8 +14,8 @@ 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 { 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 } from '@authportal/ui/components/tabs'
import { PageHead } from '../bits' import { PageHead, PageTab, PageTabsList } from '../bits'
import { AppCards, type AppCardItem } from '../canvas-blocks' import { AppCards, type AppCardItem } from '../canvas-blocks'
import { kpi } from '../kpi' import { kpi } from '../kpi'
@@ -74,10 +74,10 @@ export function AppsPage() {
]} ]}
/> />
<Tabs defaultValue="all" className="min-w-0"> <Tabs defaultValue="all" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger> <PageTab value="all" label="Доступные" count={allowed.length} />
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger> <PageTab value="no" label="Без доступа" count={denied.length} />
</TabsList> </PageTabsList>
<TabsContent value="all" className="min-w-0"> <TabsContent value="all" className="min-w-0">
<AppCards <AppCards
title="Мои приложения" title="Мои приложения"
@@ -1,47 +1,36 @@
import { useState } from 'react' import { useState } from 'react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react' import { HistoryIcon, NetworkIcon, ShieldAlertIcon, Trash2Icon, UserCogIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
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 { Input } from '@authportal/ui/components/input'
import { CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, TypedConfirm } from '../bits' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PreviewSheet, SettingRow, StatusBadge, TypedConfirm } from '../bits'
import { ActivityFeed } from '../canvas-blocks'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { MOCK_AUDIT, APP_SHORT, type Tone } from '../mock' import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
const SEVERITY_TONE: Record<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' } const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
const SEVERITY_LABEL: Record<AuditSeverity, string> = { info: 'Info', warning: 'Warning', critical: 'Critical' }
function sourceLabel(e: AuditLogEntry) { const columns: GridColumn<AuditRow>[] = [
return e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app] { id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={r.tone} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
} { id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.label}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source, cell: (r) => mono(r.source) },
function whenLabel(iso: string) { { id: 'who', title: 'Кто', size: 120, cell: (r) => mono(r.who) },
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(iso)) { id: 'ip', title: 'IP', size: 140, cell: (r) => mono(r.ip) },
} { id: 'when', title: 'Когда', size: 140, cell: (r) => mono(r.when) },
const columns: GridColumn<AuditLogEntry>[] = [
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={SEVERITY_TONE[r.severity]} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
{ id: 'sev', title: 'Важность', size: 120, sortValue: (r) => r.severity, cell: (r) => <StatusBadge tone={SEVERITY_TONE[r.severity]}>{SEVERITY_LABEL[r.severity]}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source_app, cell: (r) => <span className="text-sm">{sourceLabel(r)}</span> },
{ id: 'who', title: 'Кто', size: 140, cell: (r) => <span className="text-sm">{r.actor_name ?? '—'}</span> },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => <span className="font-mono text-[13px]">{r.ip ?? '—'}</span> },
{ id: 'when', title: 'Когда', size: 140, sortValue: (r) => r.created_at, cell: (r) => <span className="text-sm whitespace-nowrap">{whenLabel(r.created_at)}</span> },
] ]
const VIEWS = [ const VIEWS = [
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Auth', match: (r: AuditLogEntry) => r.action.startsWith('auth.') }, { label: 'Warning и выше', match: (r: AuditRow) => r.tone === 'wn' || r.tone === 'er' },
{ label: 'Пользователи', match: (r: AuditLogEntry) => r.action.startsWith('user.') }, { label: 'Critical', match: (r: AuditRow) => r.tone === 'er' },
{ label: 'Настройки', match: (r: AuditLogEntry) => /^(oidc|app_switcher|audit)\./.test(r.action) },
{ label: 'Критичные', match: (r: AuditLogEntry) => r.severity === 'critical' },
] ]
export function AuditPage() { export function AuditPage() {
const [entry, setEntry] = useState<AuditLogEntry | null>(null) const [entry, setEntry] = useState<AuditRow | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false) const [purgeOpen, setPurgeOpen] = useState(false)
const [tab, setTab] = useState('feed') const [tab, setTab] = useState('feed')
const [retention, setRetention] = useState(90) const [retention, setRetention] = useState(90)
@@ -63,21 +52,21 @@ export function AuditPage() {
items={[ items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }), kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }), kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }),
kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }), kpi('crit', 'Критичных', '3', { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }), kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'портал +5 приложений', sub: 'ingest' }),
]} ]}
/> />
<Tabs value={tab} onValueChange={setTab} className="min-w-0"> <Tabs value={tab} onValueChange={setTab} className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="feed">Лента</TabsTrigger> <PageTab value="feed" label="Лента" />
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger> <PageTab value="table" label="Таблица" count={148} />
<TabsTrigger value="retention">Ротация</TabsTrigger> <PageTab value="retention" label="Ротация" />
</TabsList> </PageTabsList>
<TabsContent value="feed" className="min-w-0"> <TabsContent value="feed" className="min-w-0">
<ActivityFeed <ActivityFeed
title="Лента изменений" title="Лента изменений"
description="Последние события · точка отражает важность, плашка — уровень" description="Последние события · иконка отражает тип, цвет — важность"
entries={[...MOCK_AUDIT].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6)} items={AUDIT_FEED}
action={ action={
<Button variant="outline" size="sm" onClick={() => setTab('table')}> <Button variant="outline" size="sm" onClick={() => setTab('table')}>
Весь журнал Весь журнал
@@ -88,33 +77,40 @@ export function AuditPage() {
<TabsContent value="table" className="min-w-0"> <TabsContent value="table" className="min-w-0">
<PreviewSectionGrid <PreviewSectionGrid
title="Журнал изменений" title="Журнал изменений"
description="Поиск по событию, пользователю и IP" description="Поиск по событию, пользователю и IP · виды по важности"
rows={MOCK_AUDIT} rows={AUDIT_ROWS}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`} searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
searchPlaceholder="Событие, пользователь, IP"
views={VIEWS} views={VIEWS}
footer="Показано 8 из 148"
onRowClick={setEntry} onRowClick={setEntry}
toolbar={
<Button variant="outline" size="sm" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button>
}
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention"> <TabsContent value="retention">
<AuditRetentionPanel <FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
retentionDays={retention} <SettingRow title="Срок хранения" description="Записи старше будут удаляться автоматически">
saving={false} <Input
purging={false} key={retention}
onSave={(days) => { defaultValue={`${retention} дней`}
setRetention(days) aria-label="Срок хранения"
toast.success(`Срок хранения: ${days} дн. (макет)`) className="w-40 max-w-full font-mono"
}} onBlur={(e) => {
onPurge={() => toast.success('Старые записи очищены (макет)')} const n = Number.parseInt(e.target.value, 10)
/> if (Number.isFinite(n) && n >= 7 && n <= 3650 && n !== retention) {
setRetention(n)
toast.success(`Срок хранения: ${n} дн. (макет)`)
}
}}
/>
</SettingRow>
<SettingRow title="Автоматическая очистка" description="Раз в сутки в 03:00">
<Switch defaultChecked aria-label="Автоматическая очистка" />
</SettingRow>
<SettingRow title="Записей в журнале" description="Из них старше срока хранения — 212">
<StatusBadge tone="mu">1 482</StatusBadge>
</SettingRow>
</FormFrame>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -127,18 +123,18 @@ export function AuditPage() {
> >
{entry ? ( {entry ? (
<> <>
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
<div> <div>
<StatusBadge tone={SEVERITY_TONE[entry.severity]}>{SEVERITY_LABEL[entry.severity]}</StatusBadge> <StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
</div> </div>
<KvList <KvList
items={[ items={[
['Событие', `${entry.action} · ${entry.summary}`], ['Источник', entry.source],
['Источник', sourceLabel(entry)], ['Кто', `${entry.who}@shnt.top · ${entry.ip}`],
['Кто', `${entry.actor_email ?? 'system'}${entry.ip ? ` · ${entry.ip}` : ''}`],
]} ]}
/> />
<CodeBlock label="Подробности"> <CodeBlock label="Подробности">
{JSON.stringify({ target_type: entry.target_type, target_id: entry.target_id, ...entry.details }, null, 2)} {'target_type: user\ntarget_id: 7c1e…a3\nbefore: { disabled: false }\nafter: { disabled: true }'}
</CodeBlock> </CodeBlock>
</> </>
) : null} ) : null}
@@ -1,11 +1,12 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
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 } from '@authportal/ui/components/tabs'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge } from '../bits' import { ExplainBanner } from '../explain-banner'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock' import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -21,11 +22,11 @@ export function LoginsPage() {
const loginCols = useMemo<GridColumn<LoginRow>[]>( const loginCols = useMemo<GridColumn<LoginRow>[]>(
() => [ () => [
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> }, { id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
{ id: 'status', title: 'Результат', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'time', title: 'Время', size: 130, cell: (r) => <span className="text-sm whitespace-nowrap">{r.time}</span> }, { id: 'time', title: 'Время', size: 130, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.time}</span> },
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[13px]">{r.ip}</span> }, { id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[12.5px]">{r.ip}</span> },
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="text-sm">{r.app}</span> }, { id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.app}</span> },
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="text-sm">{r.reason}</span> }, { id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
], ],
[], [],
) )
@@ -34,28 +35,9 @@ export function LoginsPage() {
() => [ () => [
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> }, { id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> },
{ id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="text-sm">{r.ipClient}</span> }, { id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> }, { id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="text-sm whitespace-nowrap">{r.expires}</span> }, { id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
{
id: 'act',
title: '',
size: 130,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}}
>
Завершить
</Button>
),
},
], ],
[], [],
) )
@@ -72,17 +54,13 @@ export function LoginsPage() {
</Button> </Button>
} }
/> />
<SignalAlert <ExplainBanner
tone="er" tone="er"
title="Подозрительная серия неудач: ops@shnt.top." title="Подозрительная серия неудач: ops@shnt.top."
action={ text="6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи»."
<Button variant="outline" size="sm" render={<Link to="/design-preview/users" />} nativeButton={false}>
К пользователям
</Button>
}
> >
6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи». 6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
</SignalAlert> </ExplainBanner>
<Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} /> <Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} />
<KpiStatGrid <KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
@@ -94,17 +72,18 @@ export function LoginsPage() {
]} ]}
/> />
<Tabs defaultValue="hist" className="min-w-0"> <Tabs defaultValue="hist" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="hist">История · 44</TabsTrigger> <PageTab value="hist" label="История" count={'44'} />
<TabsTrigger value="sess">Активные сессии · {sessions.length}</TabsTrigger> <PageTab value="sess" label="Активные сессии" count={sessions.length} />
</TabsList> </PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4"> <TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <PreviewSectionGrid
title="Журнал входов" title="Журнал входов"
description={`Поиск по логину и IP · период: ${PERIODS[period]}`} description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
rows={MOCK_LOGINS} rows={MOCK_LOGINS}
columns={loginCols} columns={loginCols}
getRowId={(r) => r.id} getRowId={(r) => r.id}
footer="Показано 8 из 44"
searchText={(r) => `${r.login} ${r.ip} ${r.app}`} searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
searchPlaceholder="Логин или IP" searchPlaceholder="Логин или IP"
views={[ views={[
@@ -123,6 +102,13 @@ export function LoginsPage() {
columns={sessionCols} columns={sessionCols}
getRowId={(r) => r.id} getRowId={(r) => r.id}
emptyMessage="Нет активных сессий." emptyMessage="Нет активных сессий."
rowMenu={(r) => ({
toggleLabel: 'Завершить сессию',
onToggle: () => {
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
},
})}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+34 -31
View File
@@ -15,9 +15,10 @@ import {
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'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Textarea } from '@authportal/ui/components/textarea' import { Textarea } from '@authportal/ui/components/textarea'
import { CopyList, KvFrame, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' import { ExplainBanner } from '../explain-banner'
import { CopyList, KvFrame, PageHead, PreviewSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { import {
MOCK_CLIENT_SECRET, MOCK_CLIENT_SECRET,
@@ -47,29 +48,10 @@ export function OidcPage() {
() => [ () => [
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> }, { id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> }, { id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> },
{ id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[13px]">{r.clientId}</code> }, { id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[12.5px]">{r.clientId}</code> },
{ id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.redirect}</code> }, { id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[12.5px]">{r.redirect}</code> },
{ id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="text-sm">{r.secret}</span> }, { id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="font-mono text-[12.5px]">{r.secret}</span> },
{ id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.created}</span> }, { id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.created}</span> },
{
id: 'act',
title: '',
size: 170,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setRotating(r)
}}
>
<RefreshCwIcon aria-hidden />
Обновить секрет
</Button>
),
},
], ],
[], [],
) )
@@ -106,9 +88,13 @@ export function OidcPage() {
} }
/> />
{staleCount > 0 ? ( {staleCount > 0 ? (
<SignalAlert tone="wn" title="Секрет клиента Technitium DNS старше 90 дней."> <ExplainBanner
tone="wn"
title="Секрет клиента Technitium DNS старше 90 дней."
text="Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления."
>
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления. Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
</SignalAlert> </ExplainBanner>
) : null} ) : null}
<KpiStatGrid <KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
@@ -120,10 +106,10 @@ export function OidcPage() {
]} ]}
/> />
<Tabs defaultValue="clients" className="min-w-0"> <Tabs defaultValue="clients" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="clients">Клиенты · {clients.length}</TabsTrigger> <PageTab value="clients" label="Клиенты" count={clients.length} />
<TabsTrigger value="meta">Параметры issuer</TabsTrigger> <PageTab value="meta" label="Параметры issuer" />
</TabsList> </PageTabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4"> <TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <PreviewSectionGrid
title="Зарегистрированные клиенты" title="Зарегистрированные клиенты"
@@ -138,6 +124,23 @@ export function OidcPage() {
{ label: 'Включённые', match: (r) => r.enabled }, { label: 'Включённые', match: (r) => r.enabled },
{ label: 'Выключенные', match: (r) => !r.enabled }, { label: 'Выключенные', match: (r) => !r.enabled },
]} ]}
tools={
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
<RefreshCwIcon aria-hidden />
Обновить секрет…
</Button>
}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: on } : x))) }}
rowMenu={(r) => ({
onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Выключить' : 'Включить',
onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x))),
})}
onRowClick={setEditing} onRowClick={setEditing}
/> />
</TabsContent> </TabsContent>
@@ -2,13 +2,13 @@ import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldGroup, 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'
import { Switch } from '@authportal/ui/components/switch' import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' import { ExplainBanner } from '../explain-banner'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock' import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -21,13 +21,19 @@ export function SecurityPage() {
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<PasskeyRow | null>(null) const [deleting, setDeleting] = useState<PasskeyRow | null>(null)
const [revoked, setRevoked] = useState<string[]>([])
const view = useMemo(
() => keys.map((k) => (revoked.includes(k.id) ? { ...k, tone: 'mu' as const, status: 'Отозван' } : k)),
[keys, revoked],
)
const columns = useMemo<GridColumn<PasskeyRow>[]>( const columns = useMemo<GridColumn<PasskeyRow>[]>(
() => [ () => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> }, { id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> },
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="text-sm">{r.kind}</span> }, { id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.added}</span> }, { id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.kind}</span> },
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="text-sm">{r.lastUsed}</span> }, { id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.added}</span> },
{ id: 'status', title: 'Статус', size: 200, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> }, { id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.lastUsed}</span> },
], ],
[], [],
) )
@@ -45,9 +51,13 @@ export function SecurityPage() {
} }
/> />
{keys.length === 1 ? ( {keys.length === 1 ? (
<SignalAlert tone="wn" title="Один passkey — единственная копия."> <ExplainBanner
tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</SignalAlert> </ExplainBanner>
) : null} ) : null}
<KpiStatGrid <KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
@@ -59,22 +69,38 @@ export function SecurityPage() {
]} ]}
/> />
<Tabs defaultValue="keys" className="min-w-0"> <Tabs defaultValue="keys" className="min-w-0">
<TabsList className="max-w-full justify-start overflow-x-auto"> <PageTabsList>
<TabsTrigger value="keys">Passkeys · {keys.length}</TabsTrigger> <PageTab value="keys" label="Passkeys" count={keys.length} />
<TabsTrigger value="pass">Пароль</TabsTrigger> <PageTab value="pass" label="Пароль" />
<TabsTrigger value="acc">Учётная запись</TabsTrigger> <PageTab value="acc" label="Учётная запись" />
</TabsList> </PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4"> <TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid <PreviewSectionGrid
title="Мои passkeys" title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо" description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={keys} rows={view}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' }, { label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]} ]}
rowSwitch={{
value: (r) => r.status !== 'Отозван',
onChange: (r, on) => {
setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id]))
toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)')
},
}}
rowMenu={(r) => ({
onOpen: () => {
setSelected(r)
setRenameValue(r.name)
},
toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать',
onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])),
onDelete: () => setDeleting(r),
})}
onRowClick={(r) => { onRowClick={(r) => {
setSelected(r) setSelected(r)
setRenameValue(r.name) setRenameValue(r.name)
@@ -82,41 +108,20 @@ export function SecurityPage() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="pass"> <TabsContent value="pass">
<Frame spacing="sm" className="w-full"> <FormFrame title="Пароль" description="Запасной вход, если passkey недоступен" badge="Задан">
<FrameHeader className="flex-row items-center justify-between gap-3"> <SettingRow title="Последняя смена" description="Рекомендуем менять раз в год">
<div className="flex flex-col gap-0.5"> <StatusBadge tone="mu">11.03.2026</StatusBadge>
<FrameTitle>Пароль</FrameTitle> </SettingRow>
<FrameDescription>Запасной вход, если passkey недоступен</FrameDescription> <SettingRow title="Текущий пароль" description="Для подтверждения смены">
</div> <Input type="password" defaultValue="••••••••••" autoComplete="current-password" aria-label="Текущий пароль" className="w-56 max-w-full font-mono" />
<StatusBadge tone="ok">Задан</StatusBadge> </SettingRow>
</FrameHeader> <SettingRow title="Новый пароль" description="Не короче 12 символов">
<FramePanel> <Input type="password" autoComplete="new-password" aria-label="Новый пароль" className="w-56 max-w-full font-mono" />
<FieldGroup className="max-w-xl"> </SettingRow>
<Field> <SettingRow title="Завершить остальные сессии" description="После смены пароля выйти на всех других устройствах">
<FieldLabel htmlFor="pw-cur">Текущий пароль</FieldLabel> <Switch defaultChecked aria-label="Завершить остальные сессии" />
<Input id="pw-cur" type="password" defaultValue="••••••••••" autoComplete="current-password" /> </SettingRow>
<FieldDescription>Для подтверждения смены · последняя смена 11.03.2026</FieldDescription> </FormFrame>
</Field>
<Field>
<FieldLabel htmlFor="pw-new">Новый пароль</FieldLabel>
<Input id="pw-new" type="password" autoComplete="new-password" />
<FieldDescription>Не короче 12 символов</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="pw-out" defaultChecked />
<div className="flex flex-col">
<FieldLabel htmlFor="pw-out">Завершить остальные сессии</FieldLabel>
<FieldDescription>После смены пароля выйти на всех других устройствах</FieldDescription>
</div>
</Field>
<div>
<Button variant="outline" onClick={() => toast.success('Пароль изменён (макет)')}>
Сменить пароль
</Button>
</div>
</FieldGroup>
</FramePanel>
</Frame>
</TabsContent> </TabsContent>
<TabsContent value="acc"> <TabsContent value="acc">
<KvFrame <KvFrame
@@ -1,17 +1,17 @@
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react' import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
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 { 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 } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react' import { UserPlusIcon } from 'lucide-react'
import { AccessSheet } from '../access-sheet' import { AccessSheet } from '../access-sheet'
import { diffAccess, type AccessValue } from '../access-model' import { diffAccess, type AccessValue } from '../access-model'
import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits' import { PageHead, TypedConfirm, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { ALL_APP_IDS, APP_SHORT, USER_JOURNAL } from '../mock' import { ALL_APP_IDS } from '../mock'
import { ExplainBanner } from '../explain-banner'
import { AccessMatrix } from '../access-matrix' import { AccessMatrix } from '../access-matrix'
import { UsersGrid, type UsersGridHandlers } from '../users-grid' import { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store' import { usePreviewStore } from '../preview-store'
@@ -27,11 +27,10 @@ export function UsersPage() {
const patch = usePatchUsers() const patch = usePatchUsers()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null) const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null) const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [resetFor, setResetFor] = useState<AdminUser | null>(null)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null) const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [bulkResetIds, setBulkResetIds] = useState<string[] | null>(null)
const onDeactivate = useCallback( const onDeactivate = useCallback(
(user: AdminUser) => (user: AdminUser) =>
@@ -74,17 +73,14 @@ export function UsersPage() {
) )
const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), []) const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), [])
const onResetPasskeys = useCallback((user: AdminUser) => setResetFor(user), [])
const onOpenAudit = useCallback((user: AdminUser) => setJournalFor(user), [])
const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), []) const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), [])
const handlers: UsersGridHandlers = { const handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess, onOpenAccess: onOpenAccess,
onOpenJournal: onOpenAudit,
onToggleDisabled: onDeactivate, onToggleDisabled: onDeactivate,
onResetPasskeys,
onDelete, onDelete,
onBulkDelete: (ids) => setBulkDeleteIds(ids), onBulkDelete: (ids) => setBulkDeleteIds(ids),
onBulkResetPasskeys: (ids) => setBulkResetIds(ids),
onBulkDisabled: (ids, disabled) => onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [ patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− disabled: ${String(!disabled)}`, `− disabled: ${String(!disabled)}`,
@@ -108,6 +104,13 @@ export function UsersPage() {
</Button> </Button>
} }
/> />
<ExplainBanner
tone="er"
title="Подозрительная активность: ops@shnt.top."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner>
<KpiStatGrid <KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
@@ -118,26 +121,15 @@ export function UsersPage() {
]} ]}
/> />
<Tabs defaultValue="users" className="min-w-0"> <Tabs defaultValue="users" className="min-w-0">
<TabsList> <PageTabsList>
<TabsTrigger value="users">Участники · {users.length}</TabsTrigger> <PageTab value="users" label="Участники" count={users.length} />
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger> <PageTab value="matrix" label="Доступ по приложениям" count={ALL_APP_IDS.length} />
</TabsList> </PageTabsList>
<TabsContent value="users" className="min-w-0"> <TabsContent value="users" className="min-w-0">
<UsersGrid users={users} handlers={handlers} marks={marks} /> <UsersGrid users={users} handlers={handlers} marks={marks} />
</TabsContent> </TabsContent>
<TabsContent value="matrix" className="min-w-0"> <TabsContent value="matrix" className="min-w-0">
<AccessMatrix <AccessMatrix />
users={users}
marks={marks}
onToggle={(u, appId, on) =>
patch(
[u.id],
(x) => ({ ...x, apps: on ? [...x.apps, appId] : x.apps.filter((id) => id !== appId) }),
`${on ? 'Доступ выдан' : 'Доступ снят'}: ${u.email}`,
() => [`${on ? '+' : '−'} приложение ${APP_SHORT[appId]}`],
)
}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -160,22 +152,6 @@ export function UsersPage() {
/> />
) : null} ) : null}
<PreviewSheet
open={journalFor !== null}
onOpenChange={(o) => !o && setJournalFor(null)}
title="Журнал пользователя"
description="Последние события пользователя из журнала портала"
footer={<Button variant="outline" onClick={() => setJournalFor(null)}>Закрыть</Button>}
>
<KvList
items={[
['Email', journalFor?.email ?? ''],
['Последний вход', `${journalFor?.last_login_ip ?? '—'}`],
['Passkeys', String(journalFor?.passkey_count ?? 0)],
]}
/>
<CodeBlock label={`${journalFor?.email ?? ''} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</PreviewSheet>
<TypedConfirm <TypedConfirm
open={deleteFor !== null} open={deleteFor !== null}
@@ -222,23 +198,19 @@ export function UsersPage() {
/> />
<TypedConfirm <TypedConfirm
open={resetFor !== null} open={bulkResetIds !== null}
onOpenChange={(o) => !o && setResetFor(null)} onOpenChange={(o) => !o && setBulkResetIds(null)}
title="Сбросить passkeys пользователя?" title="Сбросить passkeys выбранных пользователей?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`} description={`Все ключи у выбранных учётных записей (${bulkResetIds?.length ?? 0}) будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
expected="сбросить" expected="сбросить"
confirmLabel="Сбросить" confirmLabel="Сбросить"
onConfirm={() => { onConfirm={() => {
const target = resetFor const ids = bulkResetIds ?? []
if (!target) return patch(ids, (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${ids.length}`, (b) => [`− passkeys: ${b.passkey_count}`, '+ passkeys: 0'])
patch([target.id], (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${target.email}`, (b) => [ setBulkResetIds(null)
`− passkeys: ${b.passkey_count}`,
'+ passkeys: 0',
])
toast.warning('Passkeys сброшены в черновике', { description: 'Сохраните изменения, чтобы применить.' })
setResetFor(null)
}} }}
/> />
</> </>
) )
} }
+60 -19
View File
@@ -4,6 +4,7 @@ import {
ActivityIcon, ActivityIcon,
ArrowRightIcon, ArrowRightIcon,
CheckIcon, CheckIcon,
ChevronDownIcon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
CloudIcon, CloudIcon,
FingerprintIcon, FingerprintIcon,
@@ -65,7 +66,7 @@ import {
} from '@authportal/ui/components/sidebar' } from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils' import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits' import { Segmented } from './bits'
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock' import { APP_SHORT, FRESH_BY_PAGE, MOCK_OIDC_CLIENTS, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
import { usePreviewStore } from './preview-store' import { usePreviewStore } from './preview-store'
const APP_ICON: Record<AppId, typeof CloudIcon> = { const APP_ICON: Record<AppId, typeof CloudIcon> = {
@@ -260,6 +261,7 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
export function PreviewSidebar() { export function PreviewSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = usePreviewStore() const { pins, togglePin } = usePreviewStore()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id)) const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
@@ -283,8 +285,20 @@ export function PreviewSidebar() {
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null} {gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{group}</SidebarGroupLabel> <SidebarGroupLabel
<SidebarGroupContent> render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="cursor-pointer justify-between"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu> <SidebarMenu>
{NAV_ITEMS.filter((i) => i.group === group).map((item) => ( {NAV_ITEMS.filter((i) => i.group === group).map((item) => (
<NavEntry <NavEntry
@@ -403,7 +417,9 @@ function MonitorPopover() {
} }
function FreshnessPopover() { function FreshnessPopover() {
const [auto, setAuto] = useState(true) const pathname = useRouterState({ select: (st) => st.location.pathname })
const key = pathname.split('/')[2] ?? 'apps'
const fresh = FRESH_BY_PAGE[key] ?? FRESH_BY_PAGE.apps!
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
@@ -413,21 +429,30 @@ function FreshnessPopover() {
> >
<InfoIcon aria-hidden /> <InfoIcon aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-72 max-w-[calc(100vw-2rem)] flex-col gap-3"> <PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<div className="flex items-center gap-2"> <Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<span className="bg-success size-2 rounded-full" aria-hidden /> <p className="text-muted-foreground text-sm">{fresh.text}</p>
<span className="text-sm font-medium">Обновлено 14:02:11 · Live</span>
</div>
<p className="text-muted-foreground text-xs">Данные портала: GET /api/v1/admin/* · сессия и права из базы портала. В макете запросы не отправляются.</p>
<label className="flex items-center justify-between gap-2 text-sm">
Автообновление
<Switch checked={auto} onCheckedChange={setAuto} aria-label="Автообновление" />
</label>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} }
/** Плашка «Обновлено · Live · авто» в шапке: видна на широких экранах, как на досках. */
function FreshPill() {
const [auto, setAuto] = useState(true)
return (
<span
role="status"
className="text-muted-foreground hidden h-[30px] items-center gap-2 rounded-lg border px-2.5 text-xs @[1320px]/header:inline-flex"
>
<span className="bg-success size-1.5 rounded-full" aria-hidden />
Обновлено 14:02 · Live
<Switch size="sm" checked={auto} onCheckedChange={setAuto} aria-label="Автообновление" />
авто
</span>
)
}
export function PreviewHeader() { export function PreviewHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = usePreviewStore() const { setPaletteOpen } = usePreviewStore()
@@ -445,6 +470,7 @@ export function PreviewHeader() {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2"> <div className="ml-auto flex shrink-0 items-center gap-2">
<FreshPill />
<FreshnessPopover /> <FreshnessPopover />
<Button <Button
type="button" type="button"
@@ -479,19 +505,34 @@ export function CommandPalette() {
const entries = useMemo<PaletteEntry[]>(() => { const entries = useMemo<PaletteEntry[]>(() => {
const go = (to: string) => () => void navigate({ to }) const go = (to: string) => () => void navigate({ to })
return [ return [
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
{ id: 'act-user', group: 'Действия', label: 'Создать пользователя', hint: 'Пользователи', run: go('/design-preview/users') }, { id: 'act-user', group: 'Действия', label: 'Создать пользователя', hint: 'Пользователи', run: go('/design-preview/users') },
{ id: 'act-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') }, { id: 'act-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') },
{ id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') }, { id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') },
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
...MOCK_OIDC_CLIENTS.map((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: c.clientId, run: go('/design-preview/oidc') })),
] ]
}, [navigate]) }, [navigate])
const shown = useMemo(() => { const shown = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
return q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries const found = q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries
}, [entries, query]) // Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос обычным текстом (черновик)',
label: `Понял так: «${query.trim()}»`,
hint: 'Найдено 6 событий у 2 пользователей — открыть журнал входов с фильтром. Результат нужно проверить.',
run: () => void navigate({ to: '/design-preview/logins' }),
} as PaletteEntry,
...found,
]
}
return found
}, [entries, query, navigate])
const groups = useMemo(() => { const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>() const map = new Map<string, Array<PaletteEntry & { index: number }>>()
+285 -86
View File
@@ -1,5 +1,6 @@
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { SearchIcon } from 'lucide-react' import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table' import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_RU } from '@/lib/reui-ru' import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid' import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
@@ -16,19 +17,21 @@ import {
DataGridTableRowSelect, DataGridTableRowSelect,
DataGridTableRowSelectAll, DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table' } from '@/components/reui/data-grid/data-grid-table'
import { import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Separator } from '@authportal/ui/components/separator' import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group' import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils' import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits' import { StatusBadge } from './bits'
import type { Tone } from './mock' import type { Tone } from './mock'
import type { RowMark } from './preview-store' import type { RowMark } from './preview-store'
import { TONE_DOT } from './tone' import { TONE_DOT } from './tone'
@@ -45,13 +48,40 @@ export type GridColumn<T> = {
export type GridView<T> = { label: string; match: (row: T) => boolean } export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44 const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44 const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/** /**
* Секция-таблица макета: Frame + Filters-подобная панель + DataGrid на DataGridScrollArea. * Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* Минимальная ширина таблицы = сумма колонок, поэтому на узком экране таблица * сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* прокручивается по горизонтали внутри контейнера, а не ломает страницу. * подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/ */
export function PreviewSectionGrid<T extends object>({ export function PreviewSectionGrid<T extends object>({
title, title,
@@ -63,12 +93,16 @@ export function PreviewSectionGrid<T extends object>({
searchText, searchText,
searchPlaceholder = 'Поиск…', searchPlaceholder = 'Поиск…',
onRowClick, onRowClick,
selectable = false, selectable = true,
bulkActions, bulk,
toolbar, tools,
card,
rowSwitch,
rowMenu,
onReorder, onReorder,
rowStatus, rowStatus,
pageSize = 8, pageSize = 8,
footer,
emptyMessage = 'Нет записей по текущим фильтрам.', emptyMessage = 'Нет записей по текущим фильтрам.',
className, className,
}: { }: {
@@ -82,13 +116,21 @@ export function PreviewSectionGrid<T extends object>({
searchPlaceholder?: string searchPlaceholder?: string
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
selectable?: boolean selectable?: boolean
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode bulk?: GridBulk
toolbar?: ReactNode /** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */ /** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */ /** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined rowStatus?: (row: T) => RowMark | undefined
pageSize?: number pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string emptyMessage?: string
className?: string className?: string
}) { }) {
@@ -113,6 +155,8 @@ export function PreviewSectionGrid<T extends object>({
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize }) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => { const data = useMemo(() => {
const matchView = views?.[view]?.match const matchView = views?.[view]?.match
@@ -124,6 +168,8 @@ export function PreviewSectionGrid<T extends object>({
}) })
}, [rows, views, view, query, searchText]) }, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => { const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = [] const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) { if (onReorder) {
@@ -150,7 +196,7 @@ export function PreviewSectionGrid<T extends object>({
size: SELECT_COLUMN_SIZE, size: SELECT_COLUMN_SIZE,
}) })
} }
for (const col of columns) { for (const col of visibleColumns) {
list.push({ list.push({
id: col.id, id: col.id,
accessorFn: col.sortValue ?? (() => ''), accessorFn: col.sortValue ?? (() => ''),
@@ -162,8 +208,43 @@ export function PreviewSectionGrid<T extends object>({
meta: { headerTitle: col.title }, meta: { headerTitle: col.title },
}) })
} }
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
})
return list return list
}, [columns, selectable, onReorder]) }, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({ const table = useTable({
features: dataGridFeatures, features: dataGridFeatures,
@@ -184,12 +265,14 @@ export function PreviewSectionGrid<T extends object>({
onReorder(String(active.id), String(over.id)) onReorder(String(active.id), String(over.id))
} }
const minWidth = columns.reduce( const minWidth = visibleColumns.reduce(
(sum, c) => sum + c.size, (sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0), (selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) )
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id) const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize const paged = data.length > pageSize
const showCards = Boolean(card) && cards
return ( return (
<DataGrid <DataGrid
@@ -202,77 +285,193 @@ export function PreviewSectionGrid<T extends object>({
tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }} tableLayout={{ headerSticky: false, dense: true, 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> <FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<FrameTitle>{title}</FrameTitle> <div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription> <FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</div>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList>
<TabsTrigger value="table">Таблица</TabsTrigger>
<TabsTrigger value="cards">Карточки</TabsTrigger>
</TabsList>
</Tabs>
) : null}
{searchText ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder="Поиск"
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem
key={c.id}
checked={!hidden[c.id]}
onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}
>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</FrameHeader> </FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none"> <FramePanel className="min-w-0 p-0 shadow-none">
{views || searchText || toolbar ? ( {views ? (
<> <Tabs
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)"> value={String(view)}
<div className="flex min-w-0 flex-wrap items-center gap-2"> onValueChange={(v) => {
{views ? ( setView(Number(v))
<Segmented setPagination((p) => ({ ...p, pageIndex: 0 }))
label="Вид" }}
options={views.map((v) => `${v.label} · ${rows.filter(v.match).length}`)} >
value={view} <TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto border-b px-4">
onChange={(i) => { {views.map((v, i) => (
setView(i) <TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px] after:-bottom-px!">
setPagination((p) => ({ ...p, pageIndex: 0 })) {v.label} · {rows.filter(v.match).length}
}} </TabsTrigger>
/> ))}
) : null} </TabsList>
{searchText ? ( </Tabs>
<InputGroup className="w-56 max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
</div>
{toolbar ? <div className="flex flex-wrap items-center gap-2">{toolbar}</div> : null}
</div>
<Separator />
</>
) : null} ) : null}
{selectable && selectedIds.length > 0 && bulkActions ? ( {selectable && selectedIds.length > 0 ? (
<> <div
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2"> role="toolbar"
<span className="text-sm font-medium">Выбрано: {selectedIds.length}</span> aria-label="Массовые действия"
<div className="flex flex-wrap items-center gap-2"> className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
{bulkActions(selectedIds, () => setRowSelection({}))} >
<Button type="button" size="sm" variant="ghost" onClick={() => setRowSelection({})}> <b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
Сбросить <Button
</Button> type="button"
</div> size="sm"
</div> variant="outline"
<Separator /> onClick={() => {
</> if (bulk?.onRun) bulk.onRun(selectedIds)
) : null} else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
<DataGridScrollArea> clear()
<div className="pb-3" style={{ minWidth }}> }}
{onReorder ? ( >
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> {bulk?.onLabel ?? 'Включить'}
) : ( </Button>
<DataGridTable /> <Button
)} type="button"
size="sm"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" size="sm" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div> </div>
</DataGridScrollArea> ) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div className="pb-3" style={{ minWidth }}>
{onReorder ? (
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
) : (
<DataGridTable />
)}
</div>
</DataGridScrollArea>
)}
<Separator /> <Separator />
<FrameFooter className="flex-wrap justify-between gap-2"> <FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
Показано {table.getRowModel().rows.length} из {data.length} {footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
</span> </span>
{paged ? <DataGridPagination sizes={[8, 16, 32]} /> : null} {paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter> </FrameFooter>
</FramePanel> </FramePanel>
</Frame> </Frame>
+46 -80
View File
@@ -1,22 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import {
MoreHorizontalIcon,
PauseCircleIcon,
PlayCircleIcon,
ScrollTextIcon,
ShieldCheckIcon,
Trash2Icon,
FingerprintIcon,
} from 'lucide-react'
import type { AdminUser } from '@authportal/shared' import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { PendingChip, StatusBadge } from './bits' import { PendingChip, StatusBadge } from './bits'
import type { RowMark } from './preview-store' import type { RowMark } from './preview-store'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock' import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
@@ -42,15 +27,14 @@ function passkeyLabel(n: number) {
export type UsersGridHandlers = { export type UsersGridHandlers = {
onOpenAccess: (u: AdminUser) => void onOpenAccess: (u: AdminUser) => void
onOpenJournal: (u: AdminUser) => void
onToggleDisabled: (u: AdminUser) => void onToggleDisabled: (u: AdminUser) => void
onResetPasskeys: (u: AdminUser) => void
onDelete: (u: AdminUser) => void onDelete: (u: AdminUser) => void
onBulkDisabled: (ids: string[], disabled: boolean) => void onBulkDisabled: (ids: string[], disabled: boolean) => void
onBulkDelete: (ids: string[]) => void onBulkDelete: (ids: string[]) => void
onBulkResetPasskeys: (ids: string[]) => void
} }
/** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */ /** Каталог пользователей по доске: участник, статус, роль, приложения, passkey, активность · IP. */
export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) { export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) {
const columns = useMemo<GridColumn<AdminUser>[]>( const columns = useMemo<GridColumn<AdminUser>[]>(
() => [ () => [
@@ -61,90 +45,72 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
sortValue: (u) => u.email, sortValue: (u) => u.email,
cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />, cell: (u) => <TwoLineCell dot={userStatus(u).tone} title={u.email} sub={USER_META[u.id]?.sub ?? u.name} />,
}, },
{ id: 'status', title: 'Статус', size: 140, cell: (u) => ( {
id: 'status',
title: 'Статус',
size: 150,
cell: (u) => (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge> <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge>
{marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null} {marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
</div> </div>
), ),
}, },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="text-sm">{u.is_admin ? 'Админ' : 'Пользователь'}</span> }, { id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="font-mono text-[12.5px]">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate text-sm">{appsSummary(u)}</span> }, { id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate font-mono text-[12.5px]">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="text-sm">{passkeyLabel(u.passkey_count)}</span> }, { id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="font-mono text-[12.5px]">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[13px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> }, { id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
{
id: 'menu',
title: '',
size: 56,
cell: (u) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${u.email}`} />}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => handlers.onOpenAccess(u)}>
<ShieldCheckIcon aria-hidden />
Права доступа
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onOpenJournal(u)}>
<ScrollTextIcon aria-hidden />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onToggleDisabled(u)}>
{u.disabled ? <PlayCircleIcon aria-hidden /> : <PauseCircleIcon aria-hidden />}
{u.disabled ? 'Включить' : 'Отключить'}
</DropdownMenuItem>
<DropdownMenuItem disabled={u.passkey_count === 0} onClick={() => handlers.onResetPasskeys(u)}>
<FingerprintIcon aria-hidden />
Сбросить passkeys…
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handlers.onDelete(u)}>
<Trash2Icon aria-hidden />
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
], ],
[handlers, marks], [marks],
) )
return ( return (
<PreviewSectionGrid <PreviewSectionGrid
title="Пользователи портала" title="Пользователи портала"
description="Поиск по email и имени · виды по статусу · строка открывает права доступа" description="Поиск по email и имени · фильтры: роль, статус, приложения, passkey · колонки настраиваются, вид сохраняется"
rows={users} rows={users}
columns={columns} columns={columns}
getRowId={(u) => u.id} getRowId={(u) => u.id}
selectable
searchText={(u) => `${u.email} ${u.name}`} searchText={(u) => `${u.email} ${u.name}`}
searchPlaceholder="Email или имя"
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Активные', match: (u) => !u.disabled }, { label: 'Активные', match: (u) => !u.disabled && u.passkey_count > 0 },
{ label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 }, { label: 'Без passkey', match: (u) => !u.disabled && u.passkey_count === 0 },
{ label: 'Отключённые', match: (u) => u.disabled }, { label: 'Отключённые', match: (u) => u.disabled },
]} ]}
onRowClick={handlers.onOpenAccess} onRowClick={handlers.onOpenAccess}
rowStatus={(u) => marks[u.id]} rowStatus={(u) => marks[u.id]}
bulkActions={(ids, clear) => ( rowSwitch={{ value: (u) => !u.disabled, onChange: (u) => handlers.onToggleDisabled(u) }}
<> rowMenu={(u) => ({
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, false); clear() }}> onOpen: () => handlers.onOpenAccess(u),
Включить toggleLabel: u.disabled ? 'Включить' : 'Выключить',
</Button> onToggle: () => handlers.onToggleDisabled(u),
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, true); clear() }}> onDuplicate: () => toast('Дублирование пользователя недоступно в макете'),
Отключить onDelete: () => handlers.onDelete(u),
</Button> })}
<Button type="button" size="sm" variant="outline" onClick={() => handlers.onBulkDelete(ids)}> card={(u) => ({
Удалить… name: u.email,
</Button> sub: USER_META[u.id]?.sub ?? u.name,
</> tone: userStatus(u).tone,
status: userStatus(u).label,
cells: [
{ l: 'Роль', v: u.is_admin ? 'Админ' : 'Пользователь' },
{ l: 'Приложения', v: appsSummary(u) },
{ l: 'Passkey', v: passkeyLabel(u.passkey_count) },
{ l: 'Активность · IP', v: USER_META[u.id]?.activity ?? '—' },
],
})}
bulk={{
onLabel: 'Включить',
offLabel: 'Отключить',
onRun: (ids) => handlers.onBulkDisabled(ids, false),
offRun: (ids) => handlers.onBulkDisabled(ids, true),
onDelete: handlers.onBulkDelete,
}}
tools={(ids) => (
<Button type="button" variant="outline" size="sm" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
Сбросить passkeys…
</Button>
)} )}
/> />
) )