21 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 b89e72ec1b fix(design-preview): плитки монитора системы не разъезжаются
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 46s
CD / quality (push) Successful in 56s
CD / publish (push) Successful in 1m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:14:13 +07:00
DenozordecandClaude Sonnet 5.5 41d9325d14 fix(design-preview): убрать плашку «Обновлено · Live · авто» из шапки
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:13:16 +07:00
DenozordecandClaude Sonnet 5.5 f6466d9d7c revert(design-preview): вернуть компоновку каркаса как в MikrotikManager v2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:11:28 +07:00
DenozordecandClaude Sonnet 5.5 609bea6ad7 feat(design-preview): каркас на блоке app-shell-12 из ReUI PRO
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:03:55 +07:00
DenozordecandClaude Sonnet 5.5 f97604c23e fix(design-preview): вкладки line как в ReUI — без сплошной нижней линии
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:57:16 +07:00
DenozordecandClaude Sonnet 5.5 1395e2a70b fix(design-preview): убрать двойную линию под вкладками окна прав
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:54:32 +07:00
DenozordecandClaude Sonnet 5.5 a07cb0e55a feat(design-preview): иконки в карточках KPI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:54:54 +07:00
DenozordecandClaude Sonnet 5.5 c8d69bbdfa fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:37:40 +07:00
DenozordecandClaude Sonnet 5.5 700ad508d8 fix(design-preview): правки по ревью — пороги, высоты вкладок, контраст фокуса, счётчик вкладок
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:57:22 +07:00
DenozordecandClaude Sonnet 5.5 b82c8031c2 fix(design-preview): размеры, отступы и пороги ширины как на досках Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:53:25 +07:00
DenozordecandClaude Sonnet 5.5 3e1ad15ba3 fix(design-preview): палитра MikrotikManager v2 для макета Auth Portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 14:43:30 +07:00
DenozordecandClaude Sonnet 5.5 1b89135186 fix(design-preview): окна создания, подписи и кнопки как на досках Auth Portal
Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности
всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:44:19 +07:00
DenozordecandClaude Sonnet 5.5 cd6516edd6 fix(design-preview): полоса KPI, баннеры и шапка точно как на досках Auth Portal
Полоса показателей — один Frame с линиями и плашками, колонки по порогам контейнера; баннер в одну строку
с «Объяснить»; бренд «Единый вход · SSO», заголовки групп капсом; пороги монитора в шапке как на доске.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:41:00 +07:00
DenozordecandClaude Sonnet 5.5 afbc6fc366 feat(design-preview): перенос досок Auth Portal 1 к 1 — таблицы, вкладки, формы, права
Таблицы по анатомии доски: «Таблица/Карточки», поиск, «Колонки», виды линией со счётчиками, панель
массовых действий, переключатель и меню «⋮» в строке. Вкладки страниц — линия с мелким счётчиком.
Строки настроек (пароль, ротация журнала, меню), «Объяснить» в баннерах с панелью разбора,
палитра с ассистентом и OIDC-клиентами, сворачиваемые группы меню, плашка «Обновлено · Live»,
данные досок «Изменения» и «Доступ по приложениям», права доступа вкладками.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:35:45 +07:00
DenozordecandClaude Sonnet 5.5 709dd93548 fix(design-preview): «Приложения» и «Изменения» точно как на досках канваса
Плитки приложений и лента событий собраны по разметке доски: Frame с шапкой, сетка auto-fit
от 260px, иконка, плашка, описание и адрес; лента — время, точка с линией, плашка и подпись.
На «Приложениях» возвращена полоса KPI, «Весь журнал» открывает таблицу.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:18:06 +07:00
DenozordecandClaude Sonnet 5.5 60e0b886fc fix(design-preview): плитки приложений внутри рамки и стиль MikrotikManager v2
QuickActionGrid получил autoFit: Frame с отступами и сетка auto-fit от 17rem, плитки больше не
вылетают за границы. Центр состояния в шапке, меню пользователя (тема, плотность, контраст),
точки проблем в меню, размытая шапка, высокий контраст и видимый фокус приведены к макету v2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:15:12 +07:00
DenozordecandClaude Sonnet 5.5 8bbaab62e4 fix(design-preview): убрать страницу пользователя — дублировала панель прав
Права, сброс passkeys, отключение и удаление уже есть в панели прав и меню строки,
сессии и события — в «Входах» и «Изменениях». Страница откроется из коммита 007ece4.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:11:43 +07:00
DenozordecandClaude Sonnet 5.5 c8351424f4 feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки
администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:39:06 +07:00
DenozordecandClaude Sonnet 5.5 007ece4575 feat(design-preview): страница пользователя с рейкой вкладок (solution-users-2)
Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее
состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль»,
«Открыть профиль» в панели прав, хлебные крошки в шапке.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:35:57 +07:00
DenozordecandClaude Sonnet 5.5 f0a257220f feat(design-preview): права доступа — широкая боковая панель с двумя колонками
Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем
«Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении,
закрытие с правками спрашивает подтверждение.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:33:06 +07:00
DenozordecandClaude Sonnet 5.5 e02cfad0b1 feat(design-preview): пометки строк «Не сохранено» и «Будет удалено», массовое удаление с подтверждением
Строки с накопленными правками подсвечиваются и получают плашку (getRowStatus из DataGrid v9),
удаление применяется при «Сохранить», в каталоге пользователей добавлено «Удалить…» в панели
массовых действий.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 02:26:37 +07:00
31 changed files with 2215 additions and 1262 deletions

No files matched your search

@@ -33,6 +33,8 @@ interface QuickActionGridProps {
title?: string title?: string
description?: string description?: string
className?: string className?: string
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
autoFit?: boolean
} }
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
@@ -108,18 +110,24 @@ export function QuickActionGrid({
title = 'Быстрые действия', title = 'Быстрые действия',
description, description,
className, className,
autoFit = false,
}: QuickActionGridProps) { }: QuickActionGridProps) {
if (actions.length === 0) return null if (actions.length === 0) return null
return ( return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}> <Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && ( {(title || description) && (
<FrameHeader> <FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null} {title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null} {description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader> </FrameHeader>
)} )}
<div className={cn('grid gap-2', kpiCols(actions.length))}> <div
className={cn(
'grid gap-2',
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
)}
>
{actions.map((action) => ( {actions.map((action) => (
<FramePanel <FramePanel
key={action.id} key={action.id}
@@ -1,196 +0,0 @@
import { useMemo, useState } from 'react'
import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AdminUser,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APPS } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { CodeBlock, SignalAlert } from './bits'
import { diffAccess, type AccessValue } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
type Level = 'none' | PermissionAction
const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет доступа',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
function levelOf(perms: readonly string[], appId: AppId, sectionId: string, actions: PermissionAction[]): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) {
return action
}
}
return 'none'
}
/** Ключи для уровня: иерархия админ ⊃ запись ⊃ чтение внутри раздела. */
function keysForLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
const upto = order.indexOf(level)
return order
.slice(0, upto + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Окно «Права доступа»: вкладки по приложениям и разделам, локальное состояние, без API. */
export function AccessDialog({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [apps, setApps] = useState<AppId[]>(user.apps)
const [permissions, setPermissions] = useState<string[]>(user.permissions)
const next = useMemo<AccessValue>(() => ({ apps, permissions }), [apps, permissions])
const lines = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, next), [user, next])
function setLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level) {
setPermissions((prev) => {
const prefix = `${appId}:${sectionId}:`
const rest = prev.filter((k) => !k.startsWith(prefix))
return [...rest, ...keysForLevel(appId, sectionId, actions, level)].sort()
})
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[calc(100svh-2rem)] w-[min(52rem,calc(100vw-2rem))] max-w-none flex-col gap-4 sm:max-w-none">
<DialogHeader>
<DialogTitle>Права доступа · {user.email}</DialogTitle>
<DialogDescription>Настройка доступа к приложениям и разделам</DialogDescription>
</DialogHeader>
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="apps">Приложения</TabsTrigger>
{PERMISSION_CATALOG.map((c) => (
<TabsTrigger key={c.appId} value={c.appId}>
{APP_SHORT[c.appId]}
</TabsTrigger>
))}
<TabsTrigger value="journal">Журнал</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((app) => (
<li key={app.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<label htmlFor={`app-${app.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{app.title}</span>
<span className="text-muted-foreground text-xs">{app.description}</span>
</label>
<Switch
id={`app-${app.id}`}
checked={apps.includes(app.id)}
onCheckedChange={(on) =>
setApps((prev) => (on ? [...prev, app.id] : prev.filter((x) => x !== app.id)))
}
/>
</li>
))}
</ul>
</TabsContent>
{PERMISSION_CATALOG.map((catalog) => (
<TabsContent key={catalog.appId} value={catalog.appId} className="min-h-0 overflow-y-auto">
{!apps.includes(catalog.appId) ? (
<p className="text-muted-foreground mb-2 text-xs">
Приложение отключено на вкладке «Приложения» — права разделов не действуют.
</p>
) : null}
<ul className="flex flex-col divide-y rounded-lg border">
{catalog.sections.map((section) => {
const level = levelOf(permissions, catalog.appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5">
<div className="flex min-w-0 flex-1 basis-48 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span>
</div>
<Select
value={level}
onValueChange={(v) => v && setLevel(catalog.appId, section.id, section.actions, v as Level)}
items={options.map((o) => ({ value: o, label: LEVEL_LABEL[o] }))}
>
<SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_LABEL[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</li>
)
})}
</ul>
</TabsContent>
))}
<TabsContent value="journal" className="min-h-0 overflow-y-auto">
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
<DialogFooter className="items-center">
<span className="text-muted-foreground mr-auto text-xs">
{lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={lines.length === 0}
onClick={() => {
onSave(next)
onClose()
}}
>
Сохранить права
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,125 @@
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { CodeBlock, SignalAlert } from './bits'
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
const LEVEL_NAME: Record<Level, string> = { none: 'Нет доступа', read: 'Чтение', write: 'Запись', admin: 'Админ' }
/** Приложения, у которых на доске есть вкладка с разделами. */
const PERM_TABS: AppId[] = ['cfdm', 'vps', 'bgp', 'fw', 'mm']
/**
* Редактор прав по доске «Права доступа»: вкладки «Приложения», по приложению с разделами и «Журнал».
* Раздел — строка с названием, подсказкой и уровнем «Нет доступа / Чтение / Запись / Админ».
*/
export function AccessEditor({
user,
value,
onChange,
}: {
user: AdminUser
value: AccessValue
onChange: (next: AccessValue) => void
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList variant="line" className="h-10! w-full justify-start gap-5 overflow-x-auto">
<TabsTrigger value="apps" className="px-1 text-sm">
Приложения
</TabsTrigger>
{PERM_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="px-1 text-sm">
{id === 'vps' ? 'VPS Tracker' : APP_SHORT[id]}
</TabsTrigger>
))}
<TabsTrigger value="journal" className="px-1 text-sm">
Журнал
</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="mt-4 min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((a) => (
<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">
<span className="text-sm font-medium">{a.title}</span>
<span className="text-muted-foreground text-xs">{a.description}</span>
</label>
<Switch
id={`acc-${a.id}`}
checked={value.apps.includes(a.id)}
onCheckedChange={(on) =>
onChange({ ...value, apps: on ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
}
/>
</li>
))}
</ul>
</TabsContent>
{PERM_TABS.map((appId) => {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
if (!catalog) return null
return (
<TabsContent key={appId} value={appId} className="mt-4 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-4">
{catalog.sections.map((section) => {
const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<Field key={section.id}>
<FieldLabel>{section.title}</FieldLabel>
<Select
value={level}
onValueChange={(v) =>
v && onChange({ ...value, permissions: withLevel(value.permissions, appId, section.id, section.actions, v as Level) })
}
items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))}
>
<SelectTrigger className="w-full" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_NAME[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}</FieldDescription>
</Field>
)
})}
</div>
</TabsContent>
)
})}
<TabsContent value="journal" className="mt-4 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>
)
}
@@ -0,0 +1,53 @@
import { StatusBadge } from './bits'
import type { Tone } from './mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
type MatrixRow = {
id: string
name: string
sub: string
tone: Tone
status: string
total: string
read: string
write: string
admin: string
}
/** Данные доски «Доступ по приложениям»: сколько пользователей имеет доступ и с каким уровнем. */
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 (
<PreviewSectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={ROWS}
columns={COLUMNS}
getRowId={(r) => r.id}
views={[{ label: 'Все', match: () => true }]}
searchText={(r) => `${r.name} ${r.id}`}
bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }}
/>
)
}
+59 -1
View File
@@ -1,4 +1,10 @@
import type { AppId } from '@authportal/shared' import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APP_SHORT } from './mock' import { APP_SHORT } from './mock'
export type AccessValue = { apps: AppId[]; permissions: string[] } export type AccessValue = { apps: AppId[]; permissions: string[] }
@@ -11,3 +17,55 @@ export function diffAccess(before: AccessValue, after: AccessValue): string[] {
for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`) for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`)
return lines return lines
} }
export type Level = 'none' | 'read' | 'write' | 'admin'
export const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
/** Текущий уровень раздела: иерархия админ ⊃ запись ⊃ чтение. */
export function levelOf(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) return action
}
return 'none'
}
/** Ключи прав для уровня раздела. */
export function keysForLevel(appId: AppId, sectionId: string, actions: readonly PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
return order
.slice(0, order.indexOf(level) + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Сколько разделов приложения открыто (уровень выше «Нет»). */
export function grantCount(perms: readonly string[], appId: AppId): { granted: number; total: number } {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
const sections = catalog?.sections ?? []
const granted = sections.filter((s) => levelOf(perms, appId, s.id, s.actions) !== 'none').length
return { granted, total: sections.length }
}
/** Новое множество прав после смены уровня одного раздела. */
export function withLevel(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
level: Level,
): string[] {
const prefix = `${appId}:${sectionId}:`
return [...perms.filter((k) => !k.startsWith(prefix)), ...keysForLevel(appId, sectionId, actions, level)].sort()
}
@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react'
import type { AdminUser } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { AccessEditor } from './access-editor'
import { diffAccess, type AccessValue } from './access-model'
import { PreviewSheet } from './bits'
/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */
export function AccessSheet({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [value, setValue] = useState<AccessValue>({ apps: user.apps, permissions: user.permissions })
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
return (
<PreviewSheet
open
onOpenChange={(o) => !o && onClose()}
size="lg"
dirty={changes > 0}
title={`Права доступа · ${user.email}`}
description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение"
footer={
<>
<span className="text-muted-foreground mr-auto self-center text-xs">
{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={changes === 0}
onClick={() => {
onSave(value)
onClose()
}}
>
Сохранить права
</Button>
</>
}
>
<AccessEditor user={user} value={value} onChange={setValue} />
</PreviewSheet>
)
}
+95 -14
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'
@@ -20,6 +20,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog' } from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input' import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label' import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
@@ -59,10 +60,10 @@ export function PageHead({
actions?: ReactNode actions?: ReactNode
}) { }) {
return ( return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3"> <header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1> <h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p> <p className="text-muted-foreground text-sm">{sub}</p>
</div> </div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null} {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header> </header>
@@ -110,6 +111,8 @@ export function PreviewSheet({
description, description,
children, children,
footer, footer,
size = 'md',
dirty,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -117,8 +120,13 @@ export function PreviewSheet({
description: string description: string
children: ReactNode children: ReactNode
footer?: ReactNode footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) { }) {
const [touched, setTouched] = useState(false) const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false) const [guard, setGuard] = useState(false)
function requestChange(next: boolean) { function requestChange(next: boolean) {
@@ -136,7 +144,13 @@ export function PreviewSheet({
<SheetContent <SheetContent
side="right" side="right"
showCloseButton={false} showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(26rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none" className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
> >
<SheetHeader className="shrink-0 gap-0 p-0"> <SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4"> <div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
@@ -284,7 +298,7 @@ export function KvFrame({
items: Array<{ label: string; value: ReactNode }> items: Array<{ label: string; value: ReactNode }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -317,7 +331,7 @@ export function CopyList({
items: Array<{ label: string; value: string }> items: Array<{ label: string; value: string }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -329,19 +343,18 @@ 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" variant="outline"
variant="ghost"
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>
))} ))}
@@ -398,3 +411,71 @@ export function Segmented({
</Tabs> </Tabs>
) )
} }
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : 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 className="[--frame-panel-header-py-adjust:4px] 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>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import type { Tone } from './mock'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<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]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,194 @@
import { useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
import { PreviewSheet, SignalAlert } from './bits'
/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */
export function CreateUserPreviewSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateUserRequest) => void
}) {
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [admin, setAdmin] = useState(false)
const [touched, setTouched] = useState(false)
const passwordError = password.length < 12 ? 'Минимум 12 символов' : null
const emailError = !/^\S+@\S+\.\S+$/.test(email) ? 'Укажите email' : null
const valid = !passwordError && !emailError
function reset() {
setEmail('')
setName('')
setPassword('')
setAdmin(false)
setTouched(false)
}
return (
<PreviewSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(email || name || password || admin)}
title="Создать пользователя"
description="После создания откроются права доступа"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ email, name: name || email.split('@')[0]!, password, is_admin: admin, apps: [], permissions: [] })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(emailError)}>
<FieldLabel htmlFor="cu-email">
Email <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-email" type="email" value={email} placeholder="new.user@shnt.top" aria-invalid={touched && Boolean(emailError)} onChange={(e) => setEmail(e.target.value)} />
{touched && emailError ? <FieldError>{emailError}</FieldError> : <FieldDescription>Станет логином</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="cu-name">Имя</FieldLabel>
<Input id="cu-name" value={name} placeholder="Новый пользователь" onChange={(e) => setName(e.target.value)} />
</Field>
<Field data-invalid={touched && Boolean(passwordError)}>
<FieldLabel htmlFor="cu-pw">
Временный пароль <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-pw" type="password" value={password} aria-invalid={touched && Boolean(passwordError)} onChange={(e) => setPassword(e.target.value)} />
{touched && passwordError ? <FieldError>✕ {passwordError}</FieldError> : <FieldDescription>Пользователь заменит его на passkey</FieldDescription>}
</Field>
<Field orientation="horizontal">
<Switch id="cu-admin" checked={admin} onCheckedChange={setAdmin} />
<div className="flex flex-col">
<FieldLabel htmlFor="cu-admin">Администратор</FieldLabel>
<FieldDescription>Полный доступ к порталу и ко всем приложениям</FieldDescription>
</div>
</Field>
<SignalAlert tone="wn" title="Журнал">
Создание записывается в журнал «Изменения» с вашим именем и IP.
</SignalAlert>
</PreviewSheet>
)
}
/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */
export function CreateOidcPreviewSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateOidcClientValues) => void
}) {
const [name, setName] = useState('')
const [uris, setUris] = useState('')
const [enabled, setEnabled] = useState(true)
const [touched, setTouched] = useState(false)
const list = uris
.split('\n')
.map((x) => x.trim())
.filter(Boolean)
const urisError = list.length === 0 || list.some((u) => !/^https:\/\/[^\s*]+$/.test(u)) ? 'Укажите точный https-адрес колбэка приложения.' : null
const nameError = name.trim() ? null : 'Введите название'
const valid = !urisError && !nameError
function reset() {
setName('')
setUris('')
setEnabled(true)
setTouched(false)
}
return (
<PreviewSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(name || uris)}
title="Создать OIDC-клиент"
description="Секрет будет показан один раз после создания"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ name: name.trim(), redirect_uris: list, scopes: ['openid', 'profile', 'email'], enabled })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(nameError)}>
<FieldLabel htmlFor="co-name">
Название <span aria-hidden>*</span>
</FieldLabel>
<Input id="co-name" value={name} placeholder="Technitium DNS" aria-invalid={touched && Boolean(nameError)} onChange={(e) => setName(e.target.value)} />
{touched && nameError ? <FieldError>{nameError}</FieldError> : null}
</Field>
<Field data-invalid={touched && Boolean(urisError)}>
<FieldLabel htmlFor="co-uri">
Redirect URI <span aria-hidden>*</span>
</FieldLabel>
<Textarea
id="co-uri"
rows={4}
value={uris}
placeholder="https://dns.example.com/sso/callback"
aria-invalid={touched && Boolean(urisError)}
className="font-mono"
onChange={(e) => setUris(e.target.value)}
/>
{touched && urisError ? (
<FieldError>✕ {urisError}</FieldError>
) : (
<FieldDescription>По одному на строку · только https · точное совпадение</FieldDescription>
)}
</Field>
<Field orientation="horizontal">
<Switch id="co-on" checked={enabled} onCheckedChange={setEnabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="co-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</PreviewSheet>
)
}
+121 -4
View File
@@ -10,10 +10,99 @@ html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-chil
gap: 0.75rem; gap: 0.75rem;
} }
html[data-preview-contrast='high'] { /* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
--muted-foreground: color-mix(in oklch, var(--foreground) 82%, var(--background)); html[data-preview-density] {
--border: color-mix(in oklch, var(--foreground) 32%, var(--background)); --background: oklch(0.973 0.004 247.858);
--input: color-mix(in oklch, var(--foreground) 40%, var(--background)); --foreground: oklch(0.205 0.01 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.205 0.01 264);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.205 0.01 264);
--secondary: oklch(0.955 0.005 264);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0.006 264);
--muted-foreground: oklch(0.44 0.02 264);
--accent: oklch(0.93 0.01 264);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.88 0.008 264);
--input: oklch(0.86 0.008 264);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264);
--sidebar-foreground: oklch(0.205 0.01 264);
--sidebar-accent: oklch(0.93 0.01 264);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.88 0.008 264);
--invert: oklch(0.9 0.01 264);
--sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163);
--success-foreground: oklch(0.4 0.1 163);
--warning: oklch(0.75 0.16 80);
--warning-foreground: oklch(0.45 0.1 70);
--info: oklch(0.55 0.23 293);
--info-foreground: oklch(0.42 0.2 293);
}
html[data-preview-density].dark {
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--secondary: oklch(0.252 0.01 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.01 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--invert: oklch(0.37 0.013 285.805);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325);
--success: oklch(0.696 0.17 162.48);
--success-foreground: oklch(0.596 0.145 163.225);
--warning: oklch(0.795 0.184 86.047);
--warning-foreground: oklch(0.681 0.162 75.834);
--info: oklch(0.606 0.25 292.717);
--info-foreground: oklch(0.541 0.281 293.009);
}
html[data-preview-density][data-preview-contrast='high'] {
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
--ring: oklch(0.35 0.2 264);
}
html[data-preview-density].dark[data-preview-contrast='high'] {
--background: oklch(0 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--sidebar: oklch(0.1 0 0);
--foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 150);
--warning: oklch(0.9 0.17 95);
--warning-foreground: oklch(0.95 0.12 95);
--destructive: oklch(0.78 0.2 25);
--destructive-foreground: oklch(0.88 0.12 25);
--info: oklch(0.8 0.15 300);
--info-foreground: oklch(0.9 0.1 300);
--card: oklch(0.12 0 0);
--popover: oklch(0.1 0 0);
--muted: oklch(0.22 0 0);
--muted-foreground: oklch(0.86 0 0);
--border: oklch(1 0 0 / 55%);
--input: oklch(1 0 0 / 60%);
--ring: oklch(0.85 0.17 95);
} }
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */ /* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
@@ -26,3 +115,31 @@ html[data-preview-density],
html[data-preview-density] body { html[data-preview-density] body {
font-family: var(--font-sans); font-family: var(--font-sans);
} }
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
html[data-preview-density] [data-slot='sidebar-wrapper'] :focus-visible,
html[data-preview-density] #main-content :focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
html[data-preview-density] #main-content *,
html[data-preview-density] [data-slot='sidebar-wrapper'] * {
animation: none !important;
transition: none !important;
}
}
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
html[data-preview-density] [data-slot='data-grid-table'] thead th,
html[data-preview-density] [data-slot='data-grid-table'] thead th :is(span, button, div) {
font-size: 0.75rem;
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
/* Затемнение под панелями без размытия — как на досках. */
html[data-preview-density] :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
background: oklch(0 0 0 / 55%);
backdrop-filter: none;
}
@@ -0,0 +1,83 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
*/
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 (
<>
<div
role="status"
className={cn(
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
)}
>
<div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
<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>
</>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@authportal/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+13 -35
View File
@@ -1,46 +1,24 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import type { KpiStatItem } from '@/components/reui-kit/kpi-stat-grid'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in' export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
const ICON_CLASS: Record<KpiTone, string> = { export type KpiItem = {
neutral: 'text-muted-foreground [&_svg]:text-current', id: string
ok: 'text-success [&_svg]:text-current', label: string
wn: 'text-warning [&_svg]:text-current', value: string
er: 'text-destructive [&_svg]:text-current', /** Плашка-подсказка рядом со значением. */
in: 'text-info [&_svg]:text-current', hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
} }
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'warning-light' | 'destructive-light' | 'info-light'> = { /** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
neutral: 'outline',
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
}
/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */
export function kpi( export function kpi(
id: string, id: string,
label: string, label: string,
value: string, value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string }, opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiStatItem { ): KpiItem {
const tone = opts.tone ?? 'neutral' return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
return {
id,
label,
icon: createElement(opts.icon),
iconClassName: ICON_CLASS[tone],
variant: tone === 'wn' ? 'warning' : tone === 'er' ? 'destructive' : 'default',
value: createElement(
'span',
{ className: 'flex flex-wrap items-baseline gap-x-2' },
value,
opts.sub ? createElement('span', { className: 'text-muted-foreground text-xs font-normal' }, opts.sub) : null,
),
footer: createElement(Badge, { variant: BADGE[tone], size: 'sm', className: 'whitespace-nowrap' }, opts.badge),
}
} }
+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,33 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
ChartColumnIcon,
CloudIcon,
EyeIcon,
FingerprintIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AppId, AppSwitcherIconName } from '@authportal/shared' import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { 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 {
@@ -36,12 +11,13 @@ import {
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
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 { KpiStrip } from '../kpi-strip'
import { PageHead, PreviewSheet, StatusBadge } from '../bits' import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { AppCards } from '../canvas-blocks'
import { kpi } from '../kpi' import { kpi } from '../kpi'
import { MOCK_SWITCHER } from '../mock' import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store' import { usePreviewStore } from '../preview-store'
@@ -61,9 +37,12 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
server: { label: 'Сервер', Icon: ServerIcon }, server: { label: 'Сервер', Icon: ServerIcon },
globe: { label: 'Сеть', Icon: NetworkIcon }, globe: { label: 'Сеть', Icon: NetworkIcon },
dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon }, dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon },
chart: { label: 'График', Icon: ChartColumnIcon }, chart: { label: 'График', Icon: ChartColumnIcon }
} }
/** Названия иконок, как они подписаны на доске. */
const BOARD_ICON: Record<string, string> = { cfdm: 'cloud', vps: 'server', bgp: 'globe', fw: 'shield', dns: 'globe', cdn: 'cloud', mm: 'layout' }
const MODES = [ const MODES = [
{ value: 'JWT', label: 'JWT SSO (fragment)' }, { value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' }, { value: 'OIDC', label: 'OIDC' },
@@ -75,24 +54,12 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
url: a.url, url: a.url,
icon: a.icon, icon: a.icon,
mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC', mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC',
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 } = usePreviewStore() const { addChange, marksFor } = usePreviewStore()
const marks = marksFor('app-links')
const [rows, setRows] = useState<LinkRow[]>(INITIAL) const [rows, setRows] = useState<LinkRow[]>(INITIAL)
const rowsRef = useRef(rows) const rowsRef = useRef(rows)
useEffect(() => { useEffect(() => {
@@ -113,7 +80,9 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: enabled ? 'Показать в меню' : 'Скрыть из меню', title: enabled ? 'Показать в меню' : 'Скрыть из меню',
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`), lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
revert: () => setRows(before), resource: 'app-links',
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -134,7 +103,9 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: 'Порядок изменён', title: 'Порядок изменён',
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`], lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
revert: () => setRows(before), resource: 'app-links',
marks: { [item!.id]: 'dirty' },
revert: () => setRows(before)
}) })
}, },
[addChange], [addChange],
@@ -157,83 +128,21 @@ export function AppLinksPage() {
kind: 'chg', kind: 'chg',
title: 'Порядок сброшен', title: 'Порядок сброшен',
lines: ['− пользовательский порядок', '+ порядок каталога'], lines: ['− пользовательский порядок', '+ порядок каталога'],
revert: () => setRows(before), resource: 'app-links',
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
revert: () => setRows(before)
}) })
}, [addChange]) }, [addChange])
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]">{BOARD_ICON[r.id] ?? 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)
@@ -248,37 +157,35 @@ export function AppLinksPage() {
variant="outline" variant="outline"
onClick={() => onClick={() =>
toast('Добавление недоступно в макете', { toast('Добавление недоступно в макете', {
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.', description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.'
}) })
} }
> >
<PlusIcon aria-hidden />
Добавить приложение Добавить приложение
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }), kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }), kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }), kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }), kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: LinkIcon, badge: 'Technitium DNS' }),
]} ]}
/> />
<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="Название или адрес"
@@ -287,64 +194,53 @@ 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" variant="outline" onClick={resetOrder}>
<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]}
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} <AppCards
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>
<QuickActionGrid
title="Предпросмотр меню" title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`} description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
actions={shown.slice(0, 4).map((r) => { items={shown.slice(0, 4).map((r) => {
const { Icon } = ICONS[r.icon] const { Icon } = ICONS[r.icon]
return { return {
id: r.id, id: r.id,
title: r.name, title: r.name,
description: r.url.replace('https://', ''), desc: r.url.replace('https://', ''),
badgeLabel: r.mode, badge: r.mode,
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const), badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
icon: <Icon />, meta: r.id,
icon: <Icon />
} }
})} })}
/> />
+46 -40
View File
@@ -1,11 +1,13 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon } from 'lucide-react' import { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared' import { APPS, type AppId } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' import { KpiStrip } from '../kpi-strip'
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 { kpi } from '../kpi'
const ICONS: Record<AppId, typeof CloudIcon> = { const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon, cfdm: CloudIcon,
@@ -14,7 +16,7 @@ const ICONS: Record<AppId, typeof CloudIcon> = {
fw: ShieldIcon, fw: ShieldIcon,
dns: GlobeIcon, dns: GlobeIcon,
cdn: CloudIcon, cdn: CloudIcon,
mm: LayoutDashboardIcon, mm: LayoutDashboardIcon
} }
const NO_ACCESS: AppId[] = ['cdn'] const NO_ACCESS: AppId[] = ['cdn']
@@ -23,57 +25,61 @@ export function AppsPage() {
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id)) const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id)) const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
const toCard = (app: (typeof APPS)[number], open: boolean): AppCardItem => {
const Icon = ICONS[app.id]
const meta = `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`
return {
id: app.id,
title: app.title,
desc: app.description,
badge: open ? 'Открыть' : 'Нет доступа',
badgeVariant: open ? 'info-light' : 'outline',
meta,
icon: <Icon />,
onSelect: () =>
open
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
}
}
return ( return (
<> <>
<PageHead <PageHead
title="Приложения" title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля" sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={ actions={
<Button variant="outline" size="sm" render={<Link to="/design-preview/app-links" />} nativeButton={false}> <Button variant="outline" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
Ссылки приложений Ссылки приложений
</Button> </Button>
} }
/> />
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: BadgeCheckIcon, badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldIcon, badge: 'is_admin' }),
]}
/>
<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="flex flex-col gap-4"> <TabsContent value="all" className="min-w-0">
<QuickActionGrid <AppCards
title="Мои приложения" title="Мои приложения"
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами" description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
actions={allowed.map((app) => { items={allowed.map((a) => toCard(a, true))}
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
badgeVariant: 'info-light' as const,
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }),
}
})}
/> />
</TabsContent> </TabsContent>
<TabsContent value="no"> <TabsContent value="no" className="min-w-0">
<QuickActionGrid <AppCards
title="Недоступные приложения" title="Недоступные приложения"
description="Права не выданы · запросите у администратора" description="Права не выданы · запросите у администратора"
actions={denied.map((app) => { items={denied.map((a) => toCard(a, false))}
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Нет доступа',
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
})}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -1,48 +1,38 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared' import { KpiStrip } from '../kpi-strip'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
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 [retention, setRetention] = useState(90) const [retention, setRetention] = useState(90)
return ( return (
@@ -52,60 +42,75 @@ export function AuditPage() {
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest" sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={ actions={
<Button variant="outline" onClick={() => setPurgeOpen(true)}> <Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи… Очистить старые записи…
</Button> </Button>
} }
/> />
<KpiStatGrid <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }), kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }), kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }), kpi('crit', 'Критичных', '3', { icon: OctagonAlertIcon, tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }), kpi('src', 'Источников', '6', { icon: NetworkIcon, badge: 'портал +5 приложений' }),
]} ]}
/> />
<Tabs defaultValue="feed" 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">
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} /> <ActivityFeed
</TabsContent> title="Лента изменений"
<TabsContent value="table" className="min-w-0"> description="Последние события · иконка отражает тип, цвет — важность"
<PreviewSectionGrid items={AUDIT_FEED}
title="Журнал изменений" action={
description="Поиск по событию, пользователю и IP" <Button variant="outline" onClick={() => setTab('table')}>
rows={MOCK_AUDIT} Весь журнал
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`}
searchPlaceholder="Событие, пользователь, IP"
views={VIEWS}
onRowClick={setEntry}
toolbar={
<Button variant="outline" size="sm" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button> </Button>
} }
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention"> <TabsContent value="table" className="min-w-0">
<AuditRetentionPanel <PreviewSectionGrid
retentionDays={retention} title="Журнал изменений"
saving={false} description="Поиск по событию, пользователю и IP · виды по важности"
purging={false} rows={AUDIT_ROWS}
onSave={(days) => { columns={columns}
setRetention(days) getRowId={(r) => r.id}
toast.success(`Срок хранения: ${days} дн. (макет)`) searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
}} views={VIEWS}
onPurge={() => toast.success('Старые записи очищены (макет)')} footer="Показано 8 из 148"
onRowClick={setEntry}
/> />
</TabsContent> </TabsContent>
<TabsContent value="retention">
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
<SettingRow title="Срок хранения" description="Записи старше будут удаляться автоматически">
<Input
key={retention}
defaultValue={`${retention} дней`}
aria-label="Срок хранения"
className="w-40 max-w-full font-mono"
onBlur={(e) => {
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>
</Tabs> </Tabs>
<PreviewSheet <PreviewSheet
@@ -117,18 +122,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 { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
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 { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { 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>
),
},
], ],
[], [],
) )
@@ -67,44 +49,40 @@ export function LoginsPage() {
sub="Журнал входов и активные сессии · портал и SSO в приложения" sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={ actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}> <Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV Экспорт CSV
</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 <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('ok', 'Успешные (24ч)', '38', { icon: LogInIcon, tone: 'ok', badge: 'входы и SSO', sub: 'без ошибок' }), kpi('ok', 'Успешные (24ч)', '38', { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }), kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }), kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }), kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: '3 пользователя' }),
]} ]}
/> />
<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 +101,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>
+45 -44
View File
@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CopyIcon, PlusIcon, RefreshCwIcon, AppWindowIcon, PowerIcon, TicketIcon, KeyRoundIcon } from 'lucide-react' import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { CreateOidcPreviewSheet } from '../create-sheets'
import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import {
AlertDialog, AlertDialog,
@@ -10,14 +11,15 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle
} from '@authportal/ui/components/alert-dialog' } from '@authportal/ui/components/alert-dialog'
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,
@@ -25,7 +27,7 @@ import {
OIDC_CAPABILITIES, OIDC_CAPABILITIES,
OIDC_ENDPOINTS, OIDC_ENDPOINTS,
type OidcClientRow, type OidcClientRow,
type Tone, type Tone
} from '../mock' } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -47,29 +49,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>
),
},
], ],
[], [],
) )
@@ -84,7 +67,7 @@ export function OidcPage() {
secret: 'действует', secret: 'действует',
created: '17.10.2026', created: '17.10.2026',
enabled: values.enabled, enabled: values.enabled,
stale: false, stale: false
} }
setClients((c) => [...c, created]) setClients((c) => [...c, created])
setCreateOpen(false) setCreateOpen(false)
@@ -100,30 +83,33 @@ export function OidcPage() {
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top" sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента Создать клиента
</Button> </Button>
} }
/> />
{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 <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }), kpi('n', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }), kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: CircleCheckIcon, tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }), kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }), kpi('key', 'Ключ подписи', 'RS256', { icon: LockKeyholeIcon, badge: 'ротация вручную' }),
]} ]}
/> />
<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,21 @@ 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"
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
Обновить секрет…
</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>
@@ -147,7 +148,7 @@ export function OidcPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} /> <CreateOidcPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet <PreviewSheet
open={editing !== null} open={editing !== null}
@@ -1,14 +1,14 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
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> },
], ],
[], [],
) )
@@ -39,42 +45,60 @@ export function SecurityPage() {
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом" sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={ actions={
<Button onClick={() => setAddOpen(true)}> <Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey Добавить passkey
</Button> </Button>
} }
/> />
{keys.length === 1 ? ( <ExplainBanner
<SignalAlert tone="wn" title="Один passkey — единственная копия."> tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</SignalAlert> </ExplainBanner>
) : null} <KpiStrip
<KpiStatGrid
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: keys.length > 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }), kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }), kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }), kpi('pw', 'Пароль', 'Задан', { icon: LockIcon, badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }), kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства' }),
]} ]}
/> />
<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}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
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 +106,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
+76 -114
View File
@@ -1,52 +1,35 @@
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react' import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } 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 { CreateUserPreviewSheet } from '../create-sheets'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { KpiStrip } from '../kpi-strip'
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 { AccessSheet } from '../access-sheet'
import { AccessDialog } from '../access-dialog'
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, MOCK_USERS, USER_JOURNAL, type Tone } from '../mock' import { ALL_APP_IDS } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' import { ExplainBanner } from '../explain-banner'
import { StatusBadge } from '../bits' 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'
import { usePatchUsers } from '../user-actions'
export function UsersPage() { export function UsersPage() {
const { addChange } = usePreviewStore() const { addChange, marksFor, users, setUsers } = usePreviewStore()
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS) const marks = marksFor('users')
const usersRef = useRef(users) const usersRef = useRef(users)
useEffect(() => { useEffect(() => {
usersRef.current = users usersRef.current = users
}, [users]) }, [users])
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 [bulkResetIds, setBulkResetIds] = useState<string[] | null>(null)
/** Меняет поля у выбранных пользователей и кладёт изменение в «Не сохранено». */
const patch = useCallback(
(ids: string[], apply: (u: AdminUser) => AdminUser, title: string, lineFor: (before: AdminUser, after: AdminUser) => string[]) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange],
)
const onDeactivate = useCallback( const onDeactivate = useCallback(
(user: AdminUser) => (user: AdminUser) =>
@@ -71,46 +54,39 @@ export function UsersPage() {
last_login_ip: null, last_login_ip: null,
passkey_count: 0, passkey_count: 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString()
} }
setUsers((prev) => [...prev, created]) setUsers((prev) => [...prev, created])
addChange({ addChange({
kind: 'add', kind: 'add',
title: `Создан ${created.email}`, title: `Создан ${created.email}`,
lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`], lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`],
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)), resource: 'users',
marks: { [created.id]: 'new' },
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id))
}) })
setCreateOpen(false) setCreateOpen(false)
setAccessFor(created) setAccessFor(created)
}, },
[addChange], [addChange, setUsers],
) )
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),
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)}`,
`+ disabled: ${String(disabled)}`, `+ disabled: ${String(disabled)}`,
]), ])
} }
const matrix: MatrixRow[] = ALL_APP_IDS.map((id) => {
const mine = users.filter((u) => u.apps.includes(id))
const level = (action: string) => mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(`:${action}`))).length
const admins = mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(':admin'))).length
return { id, name: APP_SHORT[id], total: mine.length, read: level('read'), write: level('write'), admin: admins, tone: admins < 2 ? 'wn' : 'ok' }
})
const admins = users.filter((u) => u.is_admin).length const admins = users.filter((u) => u.is_admin).length
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
const disabled = users.filter((u) => u.disabled).length const disabled = users.filter((u) => u.disabled).length
@@ -122,43 +98,43 @@ export function UsersPage() {
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал" sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя Создать пользователя
</Button> </Button>
} }
/> />
<KpiStatGrid <ExplainBanner
tone="er"
title="Подозрительная активность: ops@shnt.top."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner>
<KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }), kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }), kpi('admins', 'Администраторов', String(admins), { icon: ShieldIcon, tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }), kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }), kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт' }),
]} ]}
/> />
<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} /> <UsersGrid users={users} handlers={handlers} marks={marks} />
</TabsContent> </TabsContent>
<TabsContent value="matrix" className="min-w-0"> <TabsContent value="matrix" className="min-w-0">
<PreviewSectionGrid <AccessMatrix />
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={matrix}
columns={matrixColumns}
getRowId={(r) => r.id}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} /> <CreateUserPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? ( {accessFor ? (
<AccessDialog <AccessSheet
key={accessFor.id} key={accessFor.id}
user={accessFor} user={accessFor}
onClose={() => setAccessFor(null)} onClose={() => setAccessFor(null)}
@@ -174,22 +150,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}
@@ -201,52 +161,54 @@ export function UsersPage() {
onConfirm={() => { onConfirm={() => {
const target = deleteFor const target = deleteFor
if (!target) return if (!target) return
const index = usersRef.current.findIndex((u) => u.id === target.id)
setUsers((prev) => prev.filter((u) => u.id !== target.id))
addChange({ addChange({
kind: 'del', kind: 'del',
title: `Удалён ${target.email}`, title: `Удалён ${target.email}`,
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`], lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
revert: () => resource: 'users',
setUsers((prev) => { marks: { [target.id]: 'deleted' },
const copy = [...prev] commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id))
copy.splice(Math.min(index, copy.length), 0, target)
return copy
}),
}) })
setDeleteFor(null) setDeleteFor(null)
}} }}
/> />
<TypedConfirm <TypedConfirm
open={resetFor !== null} open={bulkDeleteIds !== null}
onOpenChange={(o) => !o && setResetFor(null)} onOpenChange={(o) => !o && setBulkDeleteIds(null)}
title="Сбросить passkeys пользователя?" title="Удалить выбранных пользователей?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`} description={`Будут удалены учётные записи: ${bulkDeleteIds?.length ?? 0}. Действие попадёт в панель «Не сохранено» и в журнал.`}
expected="удалить"
confirmLabel="Удалить"
onConfirm={() => {
const ids = bulkDeleteIds ?? []
const targets = usersRef.current.filter((u) => ids.includes(u.id))
addChange({
kind: 'del',
title: `Удалено пользователей: ${targets.length}`,
lines: targets.map((t) => `− ${t.email}`),
resource: 'users',
marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])),
commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id)))
})
setBulkDeleteIds(null)
}}
/>
<TypedConfirm
open={bulkResetIds !== null}
onOpenChange={(o) => !o && setBulkResetIds(null)}
title="Сбросить passkeys выбранных пользователей?"
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)
}} }}
/> />
</> </>
) )
} }
type MatrixRow = { id: string; name: string; total: number; read: number; write: number; admin: number; tone: Tone }
const matrixColumns: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 240, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.id} /> },
{ id: 'state', title: 'Статус', size: 160, cell: (r) => <StatusBadge tone={r.tone}>{r.tone === 'wn' ? 'Мало админов' : 'Выдан'}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, sortValue: (r) => r.total, cell: (r) => <span className="text-sm tabular-nums">{r.total}</span> },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.read}</span> },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.write}</span> },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.admin}</span> },
]
+281 -258
View File
@@ -1,33 +1,31 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState, type ComponentType } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router' import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { import {
ActivityIcon, ActivityIcon,
ArrowRightIcon,
CheckIcon, CheckIcon,
ChevronDownIcon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
CloudIcon, CloudIcon,
ContrastIcon,
FingerprintIcon, FingerprintIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
KeyRoundIcon, KeyRoundIcon,
ShieldCheckIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LogOutIcon, LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
RowsIcon,
SearchIcon, SearchIcon,
ServerIcon, ServerIcon,
StarIcon, StarIcon,
ShieldIcon, ShieldIcon,
SunIcon, MoonIcon,
UserPlusIcon, PlayIcon,
PlusIcon,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared' import { APPS, type AppId } from '@authportal/shared'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -47,11 +45,9 @@ import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu' } from '@authportal/ui/components/dropdown-menu'
import { Kbd } from '@authportal/ui/components/kbd' import { Kbd } from '@authportal/ui/components/kbd'
import { Switch } from '@authportal/ui/components/switch'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover' import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { Separator } from '@authportal/ui/components/separator' import { Separator } from '@authportal/ui/components/separator'
import { import {
@@ -71,7 +67,8 @@ import {
useSidebar, useSidebar,
} 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 { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock' import { Segmented } from './bits'
import { FRESH_BY_PAGE, 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> = {
@@ -94,11 +91,11 @@ function PreviewAppSwitcher() {
render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />} render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />}
> >
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden> <div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden>
<KeyRoundIcon className="size-4" /> <ShieldCheckIcon className="size-4" />
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Auth Portal</span> <span className="truncate font-semibold">Auth Portal</span>
<span className="text-muted-foreground truncate text-xs">auth.shnt.top</span> <span className="text-muted-foreground truncate text-xs">Единый вход · SSO</span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto size-4" /> <ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -136,163 +133,90 @@ function PreviewAppSwitcher() {
) )
} }
const THEMES = [ const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
{ value: 'light', label: 'Светлая', icon: SunIcon },
{ value: 'dark', label: 'Тёмная', icon: MoonIcon },
{ value: 'system', label: 'Системная', icon: MonitorIcon },
] as const
function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: ReadonlyArray<{ value: T; label: string; icon?: typeof SunIcon }>
onChange: (v: T) => void
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{options.map((o) => {
const active = value === o.value
const Icon = o.icon
return (
<Button
key={o.value}
type="button"
role="radio"
aria-checked={active}
aria-label={o.label}
title={o.label}
variant="ghost"
size={Icon ? 'icon-xs' : 'xs'}
onClick={() => onChange(o.value)}
className={cn(
'rounded-full',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{Icon ? <Icon className="size-3.5" aria-hidden /> : o.label}
</Button>
)
})}
</div>
)
}
function PreviewNavUser() { function PreviewNavUser() {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { theme, setTheme } = useTheme() const { theme, setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const { density, setDensity, contrast, setContrast } = usePreviewStore() const { density, setDensity, contrast, setContrast } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <Popover>
<DropdownMenuTrigger <PopoverTrigger
render={ render={
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
aria-label="Профиль: Admin, admin@shnt.top" aria-label="Профиль и настройки интерфейса: Admin, admin@shnt.top"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground" className="bg-muted/40 aria-expanded:bg-muted"
/> />
} }
> >
<Avatar className="size-8 rounded-lg"> <div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback> AD
</Avatar> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate font-semibold">Admin</span> <span className="truncate text-sm font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span> <span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto size-4" /> <ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
className="min-w-64 rounded-lg" <div className="flex items-center gap-2.5">
side={isMobile ? 'bottom' : 'right'} <div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
align="end" AD
sideOffset={4} </div>
> <div className="grid leading-tight">
<DropdownMenuGroup> <span className="text-sm font-semibold">Admin</span>
<DropdownMenuLabel className="text-foreground flex items-center gap-2 py-2 font-normal"> <span className="text-muted-foreground text-xs">admin@shnt.top</span>
<Avatar className="size-8 rounded-lg"> </div>
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback> </div>
</Avatar> <Separator />
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight"> <button
<span className="truncate font-semibold">Admin</span> type="button"
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span> onClick={() => void navigate({ to: '/design-preview/account' })}
</div> className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
</DropdownMenuLabel> >
</DropdownMenuGroup> <FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
<DropdownMenuSeparator /> Безопасность
<DropdownMenuGroup> </button>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/account' })}> <div className="flex flex-col gap-1.5">
<FingerprintIcon aria-hidden /> <span className={LABEL_CLASS}>Тема</span>
Безопасность <Segmented
</DropdownMenuItem> label="Тема"
<DropdownMenuItem className="cursor-default focus:bg-transparent"> options={['Тёмная', 'Светлая']}
<PaletteIcon aria-hidden /> value={current === 'light' ? 1 : 0}
Тема onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
<div className="ml-auto"> />
<Segmented <span className={LABEL_CLASS}>Плотность</span>
label="Тема" <Segmented
value={(mounted ? theme : 'system') ?? 'system'} label="Плотность"
options={THEMES} options={['Обычная', 'Компактная']}
onChange={setTheme} value={density === 'compact' ? 1 : 0}
/> onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
</div> />
</DropdownMenuItem> <span className={LABEL_CLASS}>Контраст</span>
<DropdownMenuItem className="cursor-default focus:bg-transparent"> <Segmented
<RowsIcon aria-hidden /> label="Контраст"
Плотность options={['Стандартный', 'Высокий']}
<div className="ml-auto"> value={contrast === 'high' ? 1 : 0}
<Segmented onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
label="Плотность" />
value={density} </div>
options={[ <Separator />
{ value: 'comfortable', label: 'Обычная' }, <button
{ value: 'compact', label: 'Компактная' }, type="button"
]} onClick={() => void navigate({ to: '/design-preview/login' })}
onChange={setDensity} className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
/> >
</div> <LogOutIcon className="size-4" aria-hidden />
</DropdownMenuItem> Выйти
<DropdownMenuItem className="cursor-default focus:bg-transparent"> </button>
<ContrastIcon aria-hidden /> </PopoverContent>
Контраст </Popover>
<div className="ml-auto">
<Segmented
label="Контраст"
value={contrast}
options={[
{ value: 'standard', label: 'Обычный' },
{ value: 'high', label: 'Высокий' },
]}
onChange={setContrast}
/>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/login' })}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) )
@@ -308,19 +232,21 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
<SidebarMenuButton <SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label} tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active} isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />} render={<Link to={item.to} />}
> >
<span className="relative flex shrink-0"> <Icon className="size-4" />
<Icon className="size-4" />
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn('ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2', DOT_CLASS[item.problem.tone])}
/>
) : null}
</span>
<span>{item.label}</span> <span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton> </SidebarMenuButton>
<SidebarMenuAction <SidebarMenuAction
showOnHover={!pinned} showOnHover={!pinned}
@@ -337,16 +263,17 @@ 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">
<SidebarHeader> <SidebarHeader className="pb-1.5">
<PreviewAppSwitcher /> <PreviewAppSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{pinned.length > 0 ? ( {pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel> <SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{pinned.map((item) => ( {pinned.map((item) => (
@@ -358,11 +285,23 @@ export function PreviewSidebar() {
) : null} ) : null}
{NAV_GROUPS.map((group, gi) => ( {NAV_GROUPS.map((group, gi) => (
<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 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup> <SidebarGroup className="py-0">
<SidebarGroupLabel>{group}</SidebarGroupLabel> <SidebarGroupLabel
<SidebarGroupContent> render={
<SidebarMenu> <button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{NAV_ITEMS.filter((i) => i.group === group).map((item) => ( {NAV_ITEMS.filter((i) => i.group === group).map((item) => (
<NavEntry <NavEntry
key={item.id} key={item.id}
@@ -385,86 +324,114 @@ export function PreviewSidebar() {
) )
} }
const MONITOR_METRICS = [
{ l: 'Пользователи без passkey', v: '3 из 7', p: 57, tone: 'warning' },
{ l: 'Приложения доступны', v: '7 из 7', p: 100, tone: 'success' },
{ l: 'OIDC-клиенты', v: '1 из 2', p: 50, tone: 'warning' },
{ l: 'Сессии', v: '5', p: 40, tone: 'success' },
] as const
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() { function MonitorPopover() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok') const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<button <Button
variant="outline"
type="button" type="button"
aria-label={`Монитор Auth Portal: внимание, ${errors} ошибка, ${warnings} предупреждения`} aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 text-warning hover:bg-warning/15 focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-lg border px-3 text-sm font-medium outline-none focus-visible:ring-2" className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/> />
} }
> >
<ActivityIcon aria-hidden className="size-4" /> <ActivityIcon className="text-warning size-4" aria-hidden />
<span className="hidden @md/header:inline">Внимание</span> <span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="bg-destructive inline-flex size-5 items-center justify-center rounded-full text-xs font-semibold text-white tabular-nums"> <span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length} {bad.length}
</span> </span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-0! p-0!"> <PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5"> <div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="flex flex-col"> <div className="grid leading-tight">
<span className="text-xs font-medium">Монитор Auth Portal</span> <b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-[11px]">обновлено 14:02</span> <span className="text-muted-foreground text-xs">обновлено 14:02</span>
</div> </div>
<Badge variant="warning-light" size="sm">Внимание</Badge> <Badge variant="warning-light" size="sm">Внимание</Badge>
</div> </div>
<div className="text-muted-foreground flex gap-3 border-b px-3 py-2 text-xs"> <div className="grid grid-cols-3 gap-1.5">
<span><b className="text-destructive font-semibold">{errors}</b> ошибка</span> {[
<span><b className="text-warning font-semibold">{warnings}</b> предупреждения</span> { n: errors, l: 'сбой', c: 'text-destructive' },
<span><b className="text-success font-semibold">{MONITOR_ITEMS.length - bad.length}</b> в норме</span> { n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div> </div>
<ul className="flex flex-col"> <ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.map((m) => ( {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id} className="border-border border-b last:border-b-0"> <li key={m.id}>
<Link to={m.to} className="hover:bg-accent flex items-start gap-2.5 px-3 py-2.5 outline-none focus-visible:bg-accent"> <Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span <span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
aria-hidden <span className="grid min-w-0 flex-1 leading-tight">
className={cn( <b className="truncate text-[13px] font-medium">{m.title}</b>
'mt-1.5 size-2 shrink-0 rounded-full', <span className="text-muted-foreground truncate text-xs">{m.text}</span>
m.tone === 'er' ? 'bg-destructive' : m.tone === 'wn' ? 'bg-warning' : 'bg-success',
)}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium">{m.title}</span>
<span className="text-muted-foreground text-xs">{m.text}</span>
</span> </span>
<span className="text-muted-foreground text-[11px] whitespace-nowrap">{m.age}</span> <span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link> </Link>
</li> </li>
))} ))}
</ul> </ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to="/design-preview/logins" className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
Журнал входов
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} }
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
render={ render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" /> <Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
} }
> >
<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>
) )
@@ -476,7 +443,7 @@ export function PreviewHeader() {
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return ( return (
<header className="bg-background @container/header sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6"> <header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0"> <Breadcrumb className="min-w-0">
@@ -491,7 +458,7 @@ export function PreviewHeader() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon-sm" size="icon"
aria-label="Командная палитра (⌘K)" aria-label="Командная палитра (⌘K)"
className="md:hidden" className="md:hidden"
onClick={() => setPaletteOpen(true)} onClick={() => setPaletteOpen(true)}
@@ -509,31 +476,85 @@ type PaletteEntry = {
group: string group: string
label: string label: string
hint: string hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void run: () => void
} }
const PALETTE_USERS = [
{ name: 'admin@shnt.top', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: 'ops@shnt.top', role: 'Пользователь · 4 приложения', dot: 'er' },
{ name: 'denis@shnt.top', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: 'dns-bot@shnt.top', role: 'Пользователь · Technitium DNS', dot: 'ok' },
{ name: 'former@shnt.top', role: 'Отключён', dot: 'mu' },
] as const
const PALETTE_APPS = [
{ name: 'Cloudflare Domain Manager', role: 'SSO · JWT' },
{ name: 'VPS Tracker', role: 'SSO · JWT' },
{ name: 'EvoBGP', role: 'SSO · JWT' },
{ name: 'EvoFirewall', role: 'SSO · JWT' },
{ name: 'Technitium DNS', role: 'OIDC' },
]
const PALETTE_OIDC = [
{ name: 'Technitium DNS', role: 'client_id dns-shnt · включён' },
{ name: 'Grafana (тест)', role: 'client_id grafana-test · выключен' },
]
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() { export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = usePreviewStore() const { paletteOpen, setPaletteOpen } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [active, setActive] = useState(0) const [active, setActive] = useState(0)
const entries = useMemo<PaletteEntry[]>(() => {
const go = (to: string) => () => void navigate({ to })
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-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') },
{ id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') },
]
}, [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 match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
}, [entries, query]) const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, run: go('/design-preview/users') },
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, run: go('/design-preview/oidc') },
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, run: go('/design-preview/account') },
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: PlayIcon, run: go('/design-preview/logins') },
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...NAV_ITEMS.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...PALETTE_USERS.filter((u) => match(u.name, u.role))
.slice(0, 5)
.map((u) => ({ id: `user-${u.name}`, group: 'Пользователи', label: u.name, hint: u.role, dot: u.dot, run: go('/design-preview/users') })),
...PALETTE_APPS.filter((a) => match(a.name))
.slice(0, 4)
.map((a) => ({ id: `app-${a.name}`, group: 'Приложения', label: a.name, hint: a.role, dot: 'mu' as const, run: go('/design-preview/apps') })),
...PALETTE_OIDC.filter((c) => match(c.name))
.slice(0, 4)
.map((c) => ({ id: `oidc-${c.name}`, group: 'OIDC-клиенты', label: c.name, hint: c.role, dot: 'mu' as const, run: go('/design-preview/oidc') })),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘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,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme])
const groups = useMemo(() => { const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>() const map = new Map<string, Array<PaletteEntry & { index: number }>>()
@@ -553,17 +574,17 @@ export function CommandPalette() {
return ( return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}> <Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[20%] w-[min(40rem,calc(100vw-2rem))] max-w-none translate-y-0 gap-0 p-0 sm:max-w-none" showCloseButton={false}> <DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle> <DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription> <DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2 border-b px-3"> <div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input <input
autoFocus autoFocus
value={query} value={query}
aria-label="Поиск и команды" aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…" placeholder="Раздел, пользователь, приложение или действие…"
className="h-11 min-w-0 flex-1 bg-transparent text-sm outline-none" className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
setActive(0) setActive(0)
@@ -584,42 +605,44 @@ export function CommandPalette() {
/> />
<Kbd>Esc</Kbd> <Kbd>Esc</Kbd>
</div> </div>
<div className="max-h-[50svh] overflow-y-auto p-1.5" role="listbox" aria-label="Результаты"> <div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? ( {groups.length === 0 ? (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">Ничего не найдено</p> <p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : ( ) : (
groups.map(([group, list]) => ( groups.map(([group, list]) => (
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
<div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{group}</div> <div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => ( {list.map((e) => {
<button const Icon = e.icon
key={e.id} return (
type="button" <button
role="option" key={e.id}
aria-selected={e.index === active} type="button"
onMouseEnter={() => setActive(e.index)} role="option"
onClick={() => { aria-selected={e.index === active}
e.run() onMouseEnter={() => setActive(e.index)}
close() onClick={() => {
}} e.run()
className={cn( close()
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm outline-none', }}
e.index === active && 'bg-accent text-accent-foreground', className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
)} >
> {Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{group === 'Действия' ? <UserPlusIcon className="size-4" aria-hidden /> : null} {e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate">{e.label}</span> <span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground truncate text-xs">{e.hint}</span> <span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button> </button>
))} )
})}
</div> </div>
)) ))
)} )}
</div> </div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-3 py-2 text-xs"> <div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span> <span>↑↓ выбор</span>
<span>↵ открыть</span> <span>↵ открыть</span>
<span>Esc закрыть</span> <span>Esc закрыть</span>
<span className="ml-auto">Подсказка: «неудачные входы ops за сутки»</span>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AdminUser } from '@authportal/shared'
import { MOCK_USERS } from './mock'
import { import {
PreviewStoreContext, PreviewStoreContext,
type Contrast, type Contrast,
type Density, type Density,
type PendingChange, type PendingChange,
type RowMark,
type PreviewStore, type PreviewStore,
} from './preview-store' } from './preview-store'
@@ -15,6 +18,7 @@ import {
export function PreviewProvider({ children }: { children: ReactNode }) { export function PreviewProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingChange[]>([]) const [pending, setPending] = useState<PendingChange[]>([])
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
const [density, setDensity] = useState<Density>('comfortable') const [density, setDensity] = useState<Density>('comfortable')
const [contrast, setContrast] = useState<Contrast>('standard') const [contrast, setContrast] = useState<Contrast>('standard')
const [pins, setPins] = useState<string[]>(() => { const [pins, setPins] = useState<string[]>(() => {
@@ -77,20 +81,39 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
const applyAll = useCallback(() => { const applyAll = useCallback(() => {
const count = pendingRef.current.length const count = pendingRef.current.length
for (const c of pendingRef.current) c.commit?.()
setPending([]) setPending([])
toast.success(`Сохранено изменений: ${count}`, { toast.success(`Сохранено изменений: ${count}`, {
description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).', description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).',
}) })
}, []) }, [])
const marksFor = useCallback(
(resource: string) => {
const out: Record<string, RowMark> = {}
for (const c of pending) {
if (c.resource !== resource || !c.marks) continue
for (const [id, mark] of Object.entries(c.marks)) {
// «Будет удалено» сильнее остальных пометок.
if (out[id] !== 'deleted') out[id] = mark
}
}
return out
},
[pending],
)
const value = useMemo<PreviewStore>( const value = useMemo<PreviewStore>(
() => ({ () => ({
pending, pending,
marksFor,
addChange, addChange,
discardAll, discardAll,
applyAll, applyAll,
paletteOpen, paletteOpen,
setPaletteOpen, setPaletteOpen,
users,
setUsers,
pins, pins,
togglePin, togglePin,
density, density,
@@ -98,7 +121,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
contrast, contrast,
setContrast, setContrast,
}), }),
[pending, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast], [pending, marksFor, addChange, discardAll, applyAll, paletteOpen, users, pins, togglePin, density, contrast],
) )
return ( return (
+15 -1
View File
@@ -1,11 +1,20 @@
import { createContext, useContext } from 'react' import { createContext, useContext, type Dispatch, type SetStateAction } from 'react'
import type { AdminUser } from '@authportal/shared'
export type ChangeKind = 'add' | 'chg' | 'del' export type ChangeKind = 'add' | 'chg' | 'del'
export type RowMark = 'new' | 'dirty' | 'deleted'
export type PendingChange = { export type PendingChange = {
id: string id: string
kind: ChangeKind kind: ChangeKind
title: string title: string
/** Раздел, к которому относятся пометки строк («users», «app-links»). */
resource?: string
/** Пометки строк: «Не сохранено» (dirty/new) и «Будет удалено» (deleted). */
marks?: Record<string, RowMark>
/** Выполняется при «Сохранить»: окончательно применяет правку (например, убирает удалённую строку). */
commit?: () => void
/** Строки diff: «+ …», «− …», « … ». */ /** Строки diff: «+ …», «− …», « … ». */
lines: string[] lines: string[]
/** Откатывает локальное состояние страницы при «Отменить». */ /** Откатывает локальное состояние страницы при «Отменить». */
@@ -17,11 +26,16 @@ export type Contrast = 'standard' | 'high'
export type PreviewStore = { export type PreviewStore = {
pending: PendingChange[] pending: PendingChange[]
/** Пометки строк раздела из накопленных правок. */
marksFor: (resource: string) => Record<string, RowMark>
addChange: (change: Omit<PendingChange, 'id'>) => void addChange: (change: Omit<PendingChange, 'id'>) => void
discardAll: () => void discardAll: () => void
applyAll: () => void applyAll: () => void
paletteOpen: boolean paletteOpen: boolean
setPaletteOpen: (open: boolean) => void setPaletteOpen: (open: boolean) => void
/** Каталог пользователей: общий для списка и профиля. */
users: AdminUser[]
setUsers: Dispatch<SetStateAction<AdminUser[]>>
pins: string[] pins: string[]
togglePin: (id: string) => void togglePin: (id: string) => void
density: Density density: Density
+297 -89
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,20 +17,23 @@ 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 { TONE_DOT } from './tone' import { TONE_DOT } from './tone'
export type GridColumn<T> = { export type GridColumn<T> = {
@@ -44,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,
@@ -62,11 +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,
pageSize = 8, pageSize = 8,
footer,
emptyMessage = 'Нет записей по текущим фильтрам.', emptyMessage = 'Нет записей по текущим фильтрам.',
className, className,
}: { }: {
@@ -80,11 +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). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string emptyMessage?: string
className?: string className?: string
}) { }) {
@@ -109,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
@@ -120,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) {
@@ -146,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 ?? (() => ''),
@@ -158,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" 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,
@@ -180,12 +265,17 @@ export function PreviewSectionGrid<T extends object>({
onReorder(String(active.id), String(over.id)) onReorder(String(active.id), String(over.id))
} }
const minWidth = columns.reduce( // Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
(sum, c) => sum + c.size, const minWidth = Math.round(
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0), visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
) )
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
@@ -193,81 +283,199 @@ export function PreviewSectionGrid<T extends object>({
i18n={DATA_GRID_RU} i18n={DATA_GRID_RU}
recordCount={data.length} recordCount={data.length}
onRowClick={onRowClick} onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }} tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
> >
<Frame spacing="sm" className={cn('w-full min-w-0', className)}> <Frame className={cn('[--frame-panel-header-py-adjust:4px] 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 className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</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 px-4">
onChange={(i) => { {views.map((v, i) => (
setView(i) <TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
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> variant="outline"
</div> onClick={() => {
<Separator /> if (bulk?.onRun) bulk.onRun(selectedIds)
</> else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
) : null} clear()
<DataGridScrollArea> }}
<div className="pb-3" style={{ minWidth }}> >
{onReorder ? ( {bulk?.onLabel ?? 'Включить'}
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> </Button>
) : ( <Button
<DataGridTable /> type="button"
)} variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" 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>
+1 -1
View File
@@ -19,6 +19,6 @@ export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success', ok: 'bg-success',
wn: 'bg-warning', wn: 'bg-warning',
er: 'bg-destructive', er: 'bg-destructive',
in: 'bg-info', in: 'bg-success',
mu: 'bg-muted-foreground/50', mu: 'bg-muted-foreground/50',
} }
+3 -3
View File
@@ -37,13 +37,13 @@ export function UnsavedBar() {
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал <b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setDiffOpen(true)}> <Button type="button" variant="outline" onClick={() => setDiffOpen(true)}>
Просмотреть diff Просмотреть diff
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}> <Button type="button" variant="outline" onClick={discardAll}>
Отменить Отменить
</Button> </Button>
<Button type="button" size="sm" onClick={applyAll}> <Button type="button" onClick={applyAll}>
Сохранить Сохранить
</Button> </Button>
</div> </div>
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef } from 'react'
import type { AdminUser } from '@authportal/shared'
import { usePreviewStore } from './preview-store'
/** Меняет поля у выбранных пользователей и кладёт правку в «Не сохранено» (с откатом). */
export function usePatchUsers() {
const { users, setUsers, addChange } = usePreviewStore()
const usersRef = useRef(users)
useEffect(() => {
usersRef.current = users
}, [users])
return useCallback(
(
ids: string[],
apply: (u: AdminUser) => AdminUser,
title: string,
lineFor: (before: AdminUser, after: AdminUser) => string[],
) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
resource: 'users',
marks: Object.fromEntries(before.map((b) => [b.id, 'dirty' as const])),
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange, setUsers],
)
}
+52 -74
View File
@@ -1,23 +1,9 @@
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 { import { PendingChip, StatusBadge } from './bits'
DropdownMenu, import type { RowMark } from './preview-store'
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { StatusBadge } from './bits'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock' import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
@@ -41,15 +27,15 @@ 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
onBulkResetPasskeys: (ids: string[]) => void
} }
/** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */ /** Каталог пользователей по доске: участник, статус, роль, приложения, passkey, активность · IP. */
export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: UsersGridHandlers }) { export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) {
const columns = useMemo<GridColumn<AdminUser>[]>( const columns = useMemo<GridColumn<AdminUser>[]>(
() => [ () => [
{ {
@@ -59,80 +45,72 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U
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) => <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge> },
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="text-sm">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate text-sm">{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: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[13px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
{ {
id: 'menu', id: 'status',
title: '', title: 'Статус',
size: 56, size: 150,
cell: (u) => ( cell: (u) => (
<div onClick={(e) => e.stopPropagation()}> <div className="flex flex-wrap items-center gap-1.5">
<DropdownMenu> <StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge>
<DropdownMenuTrigger {marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
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> </div>
), ),
}, },
{ 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 font-mono text-[12.5px]">{appsSummary(u)}</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-[12.5px]">{USER_META[u.id]?.activity ?? '—'}</span> },
], ],
[handlers], [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}
bulkActions={(ids, clear) => ( rowStatus={(u) => marks[u.id]}
<> rowSwitch={{ value: (u) => !u.disabled, onChange: (u) => handlers.onToggleDisabled(u) }}
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, false); clear() }}> rowMenu={(u) => ({
Включить onOpen: () => handlers.onOpenAccess(u),
</Button> toggleLabel: u.disabled ? 'Включить' : 'Выключить',
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, true); clear() }}> onToggle: () => handlers.onToggleDisabled(u),
Отключить onDuplicate: () => toast('Дублирование пользователя недоступно в макете'),
</Button> onDelete: () => handlers.onDelete(u),
</> })}
card={(u) => ({
name: u.email,
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" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
Сбросить passkeys…
</Button>
)} )}
/> />
) )
+22 -21
View File
@@ -30,8 +30,8 @@ import { Route as DesignPreviewShellAppsRouteImport } from './routes/design-prev
import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit' import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit'
import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins' import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins'
import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc' import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc'
import { Route as DesignPreviewShellUsersRouteImport } from './routes/design-preview._shell.users'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId' import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
import { Route as DesignPreviewShellUsersIndexRouteImport } from './routes/design-preview._shell.users.index'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
@@ -139,16 +139,17 @@ const DesignPreviewShellOidcRoute = DesignPreviewShellOidcRouteImport.update({
path: '/oidc', path: '/oidc',
getParentRoute: () => DesignPreviewShellRoute, getParentRoute: () => DesignPreviewShellRoute,
} as any) } as any)
const DesignPreviewShellUsersRoute = DesignPreviewShellUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId', id: '/users/$userId',
path: '/users/$userId', path: '/users/$userId',
getParentRoute: () => AuthAdminRoute, getParentRoute: () => AuthAdminRoute,
} as any) } as any)
const DesignPreviewShellUsersIndexRoute =
DesignPreviewShellUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -169,9 +170,9 @@ export interface FileRoutesByFullPath {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute '/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin/': typeof AuthAdminIndexRoute '/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users/': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -190,9 +191,9 @@ export interface FileRoutesByTo {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute '/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin': typeof AuthAdminIndexRoute '/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -216,9 +217,9 @@ export interface FileRoutesById {
'/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute '/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute '/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute '/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/_shell/users': typeof DesignPreviewShellUsersRoute
'/_auth/admin/': typeof AuthAdminIndexRoute '/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/_shell/users/': typeof DesignPreviewShellUsersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -241,9 +242,9 @@ export interface FileRouteTypes {
| '/design-preview/audit' | '/design-preview/audit'
| '/design-preview/logins' | '/design-preview/logins'
| '/design-preview/oidc' | '/design-preview/oidc'
| '/design-preview/users'
| '/admin/' | '/admin/'
| '/admin/users/$userId' | '/admin/users/$userId'
| '/design-preview/users/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -262,9 +263,9 @@ export interface FileRouteTypes {
| '/design-preview/audit' | '/design-preview/audit'
| '/design-preview/logins' | '/design-preview/logins'
| '/design-preview/oidc' | '/design-preview/oidc'
| '/design-preview/users'
| '/admin' | '/admin'
| '/admin/users/$userId' | '/admin/users/$userId'
| '/design-preview/users'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -287,9 +288,9 @@ export interface FileRouteTypes {
| '/design-preview/_shell/audit' | '/design-preview/_shell/audit'
| '/design-preview/_shell/logins' | '/design-preview/_shell/logins'
| '/design-preview/_shell/oidc' | '/design-preview/_shell/oidc'
| '/design-preview/_shell/users'
| '/_auth/admin/' | '/_auth/admin/'
| '/_auth/admin/users/$userId' | '/_auth/admin/users/$userId'
| '/design-preview/_shell/users/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -448,13 +449,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DesignPreviewShellOidcRouteImport preLoaderRoute: typeof DesignPreviewShellOidcRouteImport
parentRoute: typeof DesignPreviewShellRoute parentRoute: typeof DesignPreviewShellRoute
} }
'/design-preview/_shell/users': {
id: '/design-preview/_shell/users'
path: '/users'
fullPath: '/design-preview/users'
preLoaderRoute: typeof DesignPreviewShellUsersRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/_auth/admin/users/$userId': { '/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId' id: '/_auth/admin/users/$userId'
path: '/users/$userId' path: '/users/$userId'
@@ -462,6 +456,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport
parentRoute: typeof AuthAdminRoute parentRoute: typeof AuthAdminRoute
} }
'/design-preview/_shell/users/': {
id: '/design-preview/_shell/users/'
path: '/users'
fullPath: '/design-preview/users/'
preLoaderRoute: typeof DesignPreviewShellUsersIndexRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
} }
} }
@@ -508,7 +509,7 @@ interface DesignPreviewShellRouteChildren {
DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
DesignPreviewShellUsersRoute: typeof DesignPreviewShellUsersRoute DesignPreviewShellUsersIndexRoute: typeof DesignPreviewShellUsersIndexRoute
} }
const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = { const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
@@ -518,7 +519,7 @@ const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute, DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute, DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute, DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
DesignPreviewShellUsersRoute: DesignPreviewShellUsersRoute, DesignPreviewShellUsersIndexRoute: DesignPreviewShellUsersIndexRoute,
} }
const DesignPreviewShellRouteWithChildren = const DesignPreviewShellRouteWithChildren =
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { UsersPage } from '@/design-preview/pages/users-page' import { UsersPage } from '@/design-preview/pages/users-page'
export const Route = createFileRoute('/design-preview/_shell/users')({ export const Route = createFileRoute('/design-preview/_shell/users/')({
component: UsersPage, component: UsersPage,
}) })