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 <[email protected]>
2026-10-23 12:14:13 +07:00
DenozordecandClaude Sonnet 5.5 41d9325d14 fix(design-preview): убрать плашку «Обновлено · Live · авто» из шапки
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
2026-10-23 11:57:16 +07:00
DenozordecandClaude Sonnet 5.5 1395e2a70b fix(design-preview): убрать двойную линию под вкладками окна прав
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 18:54:32 +07:00
DenozordecandClaude Sonnet 5.5 a07cb0e55a feat(design-preview): иконки в карточках KPI
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
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 <[email protected]>
2026-10-18 16:37:40 +07:00
DenozordecandClaude Sonnet 5.5 700ad508d8 fix(design-preview): правки по ревью — пороги, высоты вкладок, контраст фокуса, счётчик вкладок
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
2026-10-18 02:35:57 +07:00
DenozordecandClaude Sonnet 5.5 f0a257220f feat(design-preview): права доступа — широкая боковая панель с двумя колонками
Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем
«Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении,
закрытие с правками спрашивает подтверждение.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
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 <[email protected]>
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
description?: string
className?: string
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
autoFit?: boolean
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
@@ -108,18 +110,24 @@ export function QuickActionGrid({
title = 'Быстрые действия',
description,
className,
autoFit = false,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
<Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</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) => (
<FramePanel
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'
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}`)
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 { CircleAlertIcon, CopyIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
@@ -20,6 +20,7 @@ import {
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
@@ -59,10 +60,10 @@ export function PageHead({
actions?: ReactNode
}) {
return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p>
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
@@ -110,6 +111,8 @@ export function PreviewSheet({
description,
children,
footer,
size = 'md',
dirty,
}: {
open: boolean
onOpenChange: (open: boolean) => void
@@ -117,8 +120,13 @@ export function PreviewSheet({
description: string
children: 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)
function requestChange(next: boolean) {
@@ -136,7 +144,13 @@ export function PreviewSheet({
<SheetContent
side="right"
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">
<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 }>
}) {
return (
<Frame spacing="sm" className="w-full">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
@@ -317,7 +331,7 @@ export function CopyList({
items: Array<{ label: string; value: string }>
}) {
return (
<Frame spacing="sm" className="w-full">
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
@@ -329,19 +343,18 @@ export function CopyList({
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"
>
<code className="min-w-0 flex-1 basis-64 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground basis-40 text-xs">{item.label}</span>
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
<CopyIcon aria-hidden />
Копировать
</Button>
</li>
))}
@@ -398,3 +411,71 @@ export function Segmented({
</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="[email protected]" 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;
}
html[data-preview-contrast='high'] {
--muted-foreground: color-mix(in oklch, var(--foreground) 82%, var(--background));
--border: color-mix(in oklch, var(--foreground) 32%, var(--background));
--input: color-mix(in oklch, var(--foreground) 40%, var(--background));
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] {
--background: oklch(0.973 0.004 247.858);
--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) — только пока смонтирован макет. */
@@ -26,3 +115,31 @@ html[data-preview-density],
html[data-preview-density] body {
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/[email protected]'}
</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 { Badge } from '@/components/reui/badge'
import type { KpiStatItem } from '@/components/reui-kit/kpi-stat-grid'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
const ICON_CLASS: Record<KpiTone, string> = {
neutral: 'text-muted-foreground [&_svg]:text-current',
ok: 'text-success [&_svg]:text-current',
wn: 'text-warning [&_svg]:text-current',
er: 'text-destructive [&_svg]:text-current',
in: 'text-info [&_svg]:text-current',
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
const BADGE: Record<KpiTone, 'outline' | 'success-light' | 'warning-light' | 'destructive-light' | 'info-light'> = {
neutral: 'outline',
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
}
/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string },
): KpiStatItem {
const tone = opts.tone ?? 'neutral'
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),
}
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
+45 -1
View File
@@ -151,7 +151,7 @@ export const MOCK_USERS: AdminUser[] = [
'[email protected]',
'Operations',
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'],
{
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-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: 'Отключён [email protected]', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '14:02' },
{ id: 'e2', action: 'user.access_update', summary: '[email protected] · +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: 'Создан [email protected]', 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: 'Отключён пользователь [email protected]', text: 'admin · Auth Portal · 203.0.113.10', label: 'Warning', tone: 'wn' },
{ t: '13:50', title: 'Права обновлены: [email protected]', 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 {
ChartColumnIcon,
CloudIcon,
EyeIcon,
FingerprintIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
@@ -36,12 +11,13 @@ import {
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KpiStrip } from '../kpi-strip'
import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { AppCards } from '../canvas-blocks'
import { kpi } from '../kpi'
import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store'
@@ -61,9 +37,12 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
server: { label: 'Сервер', Icon: ServerIcon },
globe: { label: 'Сеть', Icon: NetworkIcon },
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 = [
{ value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' },
@@ -75,24 +54,12 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
url: a.url,
icon: a.icon,
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() {
const { addChange } = usePreviewStore()
const { addChange, marksFor } = usePreviewStore()
const marks = marksFor('app-links')
const [rows, setRows] = useState<LinkRow[]>(INITIAL)
const rowsRef = useRef(rows)
useEffect(() => {
@@ -113,7 +80,9 @@ export function AppLinksPage() {
kind: 'chg',
title: enabled ? 'Показать в меню' : 'Скрыть из меню',
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],
@@ -134,7 +103,9 @@ export function AppLinksPage() {
kind: 'chg',
title: 'Порядок изменён',
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
revert: () => setRows(before),
resource: 'app-links',
marks: { [item!.id]: 'dirty' },
revert: () => setRows(before)
})
},
[addChange],
@@ -157,83 +128,21 @@ export function AppLinksPage() {
kind: 'chg',
title: 'Порядок сброшен',
lines: ['− пользовательский порядок', '+ порядок каталога'],
revert: () => setRows(before),
resource: 'app-links',
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
revert: () => setRows(before)
})
}, [addChange])
const columns = useMemo<GridColumn<LinkRow>[]>(
() => [
{
id: 'order',
title: '№',
size: 52,
cell: (r) => (
<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>
),
},
{ id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
{ id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
{ id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{BOARD_ICON[r.id] ?? r.icon}</span> },
{ id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
{ id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
],
[rows, setEnabled, moveTo],
[],
)
const shown = rows.filter((r) => r.enabled)
@@ -248,37 +157,35 @@ export function AppLinksPage() {
variant="outline"
onClick={() =>
toast('Добавление недоступно в макете', {
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.',
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.'
})
}
>
<PlusIcon aria-hidden />
Добавить приложение
</Button>
}
/>
<KpiStatGrid
<KpiStrip
aria-label="Показатели"
items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }),
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
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: LinkIcon, badge: 'Technitium DNS' }),
]}
/>
<Tabs defaultValue="list" className="min-w-0">
<TabsList>
<TabsTrigger value="list">Приложения · {rows.length}</TabsTrigger>
<TabsTrigger value="menu">Меню</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTab value="list" label="Приложения" count={rows.length} />
<PageTab value="menu" label="Меню" />
</PageTabsList>
<TabsContent value="list" className="min-w-0">
<PreviewSectionGrid
title="Ссылки сервисов"
description="Перетащите строку за ручку: порядок строк = порядок в меню · изменения применяются после сохранения"
description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
rows={rows}
columns={columns}
getRowId={(r) => r.id}
selectable
pageSize={10}
searchText={(r) => `${r.name} ${r.id} ${r.url}`}
searchPlaceholder="Название или адрес"
@@ -287,64 +194,53 @@ export function AppLinksPage() {
{ label: 'В меню', match: (r) => r.enabled },
{ label: 'Скрытые', match: (r) => !r.enabled },
]}
toolbar={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden />
tools={
<Button type="button" variant="outline" onClick={resetOrder}>
Сбросить порядок
</Button>
}
bulkActions={(ids, clear) => (
<>
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], true); clear() }}>
Показать в меню
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], false); clear() }}>
Скрыть
</Button>
</>
)}
bulk={{
onLabel: 'Показать в меню',
offLabel: 'Скрыть',
onRun: (ids) => setEnabled(ids as AppId[], true),
offRun: (ids) => setEnabled(ids as AppId[], false)
}}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
rowMenu={(r) => ({
onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
onToggle: () => setEnabled([r.id], !r.enabled)
})}
onReorder={dropOn}
rowStatus={(r) => marks[r.id]}
onRowClick={setEditing}
/>
</TabsContent>
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle>Переключатель приложений</FrameTitle>
<FrameDescription>Как выглядит меню в шапке каждого приложения</FrameDescription>
</div>
<StatusBadge tone="ok">Опубликовано</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<Input
value={menuLabel}
onChange={(e) => setMenuLabel(e.target.value)}
aria-label="Заголовок меню"
className="w-56 max-w-full"
/>
</SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
</SettingRow>
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow>
</FramePanel>
</Frame>
<QuickActionGrid
<FormFrame title="Переключатель приложений" description="Как выглядит меню в шапке каждого приложения" badge="Опубликовано">
<SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<Input value={menuLabel} onChange={(e) => setMenuLabel(e.target.value)} aria-label="Заголовок меню" className="w-56 max-w-full" />
</SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
</SettingRow>
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow>
</FormFrame>
<AppCards
title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
actions={shown.slice(0, 4).map((r) => {
items={shown.slice(0, 4).map((r) => {
const { Icon } = ICONS[r.icon]
return {
id: r.id,
title: r.name,
description: r.url.replace('https://', ''),
badgeLabel: r.mode,
desc: r.url.replace('https://', ''),
badge: r.mode,
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
icon: <Icon />,
meta: r.id,
icon: <Icon />
}
})}
/>
+46 -40
View File
@@ -1,11 +1,13 @@
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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { PageHead } from '../bits'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { PageHead, PageTab, PageTabsList } from '../bits'
import { AppCards, type AppCardItem } from '../canvas-blocks'
import { kpi } from '../kpi'
const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
@@ -14,7 +16,7 @@ const ICONS: Record<AppId, typeof CloudIcon> = {
fw: ShieldIcon,
dns: GlobeIcon,
cdn: CloudIcon,
mm: LayoutDashboardIcon,
mm: LayoutDashboardIcon
}
const NO_ACCESS: AppId[] = ['cdn']
@@ -23,57 +25,61 @@ export function AppsPage() {
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
const toCard = (app: (typeof APPS)[number], open: boolean): AppCardItem => {
const Icon = ICONS[app.id]
const meta = `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`
return {
id: app.id,
title: app.title,
desc: app.description,
badge: open ? 'Открыть' : 'Нет доступа',
badgeVariant: open ? 'info-light' : 'outline',
meta,
icon: <Icon />,
onSelect: () =>
open
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
}
}
return (
<>
<PageHead
title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={
<Button variant="outline" size="sm" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
<Button variant="outline" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
Ссылки приложений
</Button>
}
/>
<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">
<TabsList>
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex flex-col gap-4">
<QuickActionGrid
<PageTabsList>
<PageTab value="all" label="Доступные" count={allowed.length} />
<PageTab value="no" label="Без доступа" count={denied.length} />
</PageTabsList>
<TabsContent value="all" className="min-w-0">
<AppCards
title="Мои приложения"
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами"
actions={allowed.map((app) => {
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
badgeVariant: 'info-light' as const,
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }),
}
})}
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
items={allowed.map((a) => toCard(a, true))}
/>
</TabsContent>
<TabsContent value="no">
<QuickActionGrid
<TabsContent value="no" className="min-w-0">
<AppCards
title="Недоступные приложения"
description="Права не выданы · запросите у администратора"
actions={denied.map((app) => {
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Нет доступа',
meta: `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`,
icon: <Icon />,
onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
})}
items={denied.map((a) => toCard(a, false))}
/>
</TabsContent>
</Tabs>
@@ -1,48 +1,38 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react'
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, TypedConfirm } from '../bits'
import { Input } from '@authportal/ui/components/input'
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 { 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'
const SEVERITY_TONE: Record<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' }
const SEVERITY_LABEL: Record<AuditSeverity, string> = { info: 'Info', warning: 'Warning', critical: 'Critical' }
const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
function sourceLabel(e: AuditLogEntry) {
return e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app]
}
function whenLabel(iso: string) {
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
}
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 columns: GridColumn<AuditRow>[] = [
{ 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) },
{ id: 'who', title: 'Кто', size: 120, cell: (r) => mono(r.who) },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => mono(r.ip) },
{ id: 'when', title: 'Когда', size: 140, cell: (r) => mono(r.when) },
]
const VIEWS = [
{ label: 'Все', match: () => true },
{ label: 'Auth', match: (r: AuditLogEntry) => r.action.startsWith('auth.') },
{ label: 'Пользователи', match: (r: AuditLogEntry) => r.action.startsWith('user.') },
{ label: 'Настройки', match: (r: AuditLogEntry) => /^(oidc|app_switcher|audit)\./.test(r.action) },
{ label: 'Критичные', match: (r: AuditLogEntry) => r.severity === 'critical' },
{ label: 'Warning и выше', match: (r: AuditRow) => r.tone === 'wn' || r.tone === 'er' },
{ label: 'Critical', match: (r: AuditRow) => r.tone === 'er' },
]
export function AuditPage() {
const [entry, setEntry] = useState<AuditLogEntry | null>(null)
const [entry, setEntry] = useState<AuditRow | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [tab, setTab] = useState('feed')
const [retention, setRetention] = useState(90)
return (
@@ -52,60 +42,75 @@ export function AuditPage() {
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи…
</Button>
}
/>
<KpiStatGrid
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }),
kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }),
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', '3', { icon: OctagonAlertIcon, tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, badge: 'портал +5 приложений' }),
]}
/>
<Tabs defaultValue="feed" className="min-w-0">
<TabsList>
<TabsTrigger value="feed">Лента</TabsTrigger>
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger>
<TabsTrigger value="retention">Ротация</TabsTrigger>
</TabsList>
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
<PageTabsList>
<PageTab value="feed" label="Лента" />
<PageTab value="table" label="Таблица" count={148} />
<PageTab value="retention" label="Ротация" />
</PageTabsList>
<TabsContent value="feed" className="min-w-0">
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} />
</TabsContent>
<TabsContent value="table" className="min-w-0">
<PreviewSectionGrid
title="Журнал изменений"
description="Поиск по событию, пользователю и IP"
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
<ActivityFeed
title="Лента изменений"
description="Последние события · иконка отражает тип, цвет — важность"
items={AUDIT_FEED}
action={
<Button variant="outline" onClick={() => setTab('table')}>
Весь журнал
</Button>
}
/>
</TabsContent>
<TabsContent value="retention">
<AuditRetentionPanel
retentionDays={retention}
saving={false}
purging={false}
onSave={(days) => {
setRetention(days)
toast.success(`Срок хранения: ${days} дн. (макет)`)
}}
onPurge={() => toast.success('Старые записи очищены (макет)')}
<TabsContent value="table" className="min-w-0">
<PreviewSectionGrid
title="Журнал изменений"
description="Поиск по событию, пользователю и IP · виды по важности"
rows={AUDIT_ROWS}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
views={VIEWS}
footer="Показано 8 из 148"
onRowClick={setEntry}
/>
</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>
<PreviewSheet
@@ -117,18 +122,18 @@ export function AuditPage() {
>
{entry ? (
<>
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
<div>
<StatusBadge tone={SEVERITY_TONE[entry.severity]}>{SEVERITY_LABEL[entry.severity]}</StatusBadge>
<StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
</div>
<KvList
items={[
['Событие', `${entry.action} · ${entry.summary}`],
['Источник', sourceLabel(entry)],
['Кто', `${entry.actor_email ?? 'system'}${entry.ip ? ` · ${entry.ip}` : ''}`],
['Источник', entry.source],
['Кто', `${entry.who}@shnt.top · ${entry.ip}`],
]}
/>
<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>
</>
) : null}
@@ -1,11 +1,12 @@
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge } from '../bits'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '../explain-banner'
import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList } from '../bits'
import { kpi } from '../kpi'
import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -21,11 +22,11 @@ export function LoginsPage() {
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: '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: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[13px]">{r.ip}</span> },
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="text-sm">{r.app}</span> },
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="text-sm">{r.reason}</span> },
{ id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ 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-[12.5px]">{r.ip}</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="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: '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: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="text-sm 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>
),
},
{ 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="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
],
[],
)
@@ -67,44 +49,40 @@ export function LoginsPage() {
sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button>
}
/>
<SignalAlert
<ExplainBanner
tone="er"
title="Подозрительная серия неудач: [email protected]."
action={
<Button variant="outline" size="sm" render={<Link to="/design-preview/users" />} nativeButton={false}>
К пользователям
</Button>
}
text="6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи».
</SignalAlert>
6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
</ExplainBanner>
<Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} />
<KpiStatGrid
<KpiStrip
aria-label="Показатели"
items={[
kpi('ok', 'Успешные (24ч)', '38', { icon: LogInIcon, tone: 'ok', badge: 'входы и SSO', sub: 'без ошибок' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль', sub: 'с одного IP' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых', sub: 'адреса' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, tone: 'in', badge: '3 пользователя', sub: 'токены' }),
kpi('ok', 'Успешные (24ч)', '38', { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: '3 пользователя' }),
]}
/>
<Tabs defaultValue="hist" className="min-w-0">
<TabsList>
<TabsTrigger value="hist">История · 44</TabsTrigger>
<TabsTrigger value="sess">Активные сессии · {sessions.length}</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTab value="hist" label="История" count={'44'} />
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
</PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Журнал входов"
description={`Поиск по логину и IP · период: ${PERIODS[period]}`}
description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
rows={MOCK_LOGINS}
columns={loginCols}
getRowId={(r) => r.id}
footer="Показано 8 из 44"
searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
searchPlaceholder="Логин или IP"
views={[
@@ -123,6 +101,13 @@ export function LoginsPage() {
columns={sessionCols}
getRowId={(r) => r.id}
emptyMessage="Нет активных сессий."
rowMenu={(r) => ({
toggleLabel: 'Завершить сессию',
onToggle: () => {
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}
})}
/>
</TabsContent>
</Tabs>
+45 -44
View File
@@ -1,8 +1,9 @@
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 { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { CreateOidcPreviewSheet } from '../create-sheets'
import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button'
import {
AlertDialog,
@@ -10,14 +11,15 @@ import {
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTitle
} from '@authportal/ui/components/alert-dialog'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
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 { 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 {
MOCK_CLIENT_SECRET,
@@ -25,7 +27,7 @@ import {
OIDC_CAPABILITIES,
OIDC_ENDPOINTS,
type OidcClientRow,
type Tone,
type Tone
} from '../mock'
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: '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: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.redirect}</code> },
{ id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="text-sm">{r.secret}</span> },
{ id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="text-sm tabular-nums">{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>
),
},
{ 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-[12.5px]">{r.redirect}</code> },
{ 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="font-mono text-[12.5px]">{r.created}</span> },
],
[],
)
@@ -84,7 +67,7 @@ export function OidcPage() {
secret: 'действует',
created: '17.10.2026',
enabled: values.enabled,
stale: false,
stale: false
}
setClients((c) => [...c, created])
setCreateOpen(false)
@@ -100,30 +83,33 @@ export function OidcPage() {
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
actions={
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента
</Button>
}
/>
{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). Старый секрет перестанет работать сразу после обновления.
</SignalAlert>
</ExplainBanner>
) : null}
<KpiStatGrid
<KpiStrip
aria-label="Показатели"
items={[
kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }),
kpi('n', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: CircleCheckIcon, tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: LockKeyholeIcon, badge: 'ротация вручную' }),
]}
/>
<Tabs defaultValue="clients" className="min-w-0">
<TabsList>
<TabsTrigger value="clients">Клиенты · {clients.length}</TabsTrigger>
<TabsTrigger value="meta">Параметры issuer</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTab value="clients" label="Клиенты" count={clients.length} />
<PageTab value="meta" label="Параметры issuer" />
</PageTabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Зарегистрированные клиенты"
@@ -138,6 +124,21 @@ export function OidcPage() {
{ 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}
/>
</TabsContent>
@@ -147,7 +148,7 @@ export function OidcPage() {
</TabsContent>
</Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} />
<CreateOidcPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet
open={editing !== null}
@@ -1,14 +1,14 @@
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 { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { KpiStrip } from '../kpi-strip'
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 { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '../explain-banner'
import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
import { kpi } from '../kpi'
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
@@ -21,13 +21,19 @@ export function SecurityPage() {
const [renameValue, setRenameValue] = useState('')
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>[]>(
() => [
{ 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: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.added}</span> },
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="text-sm">{r.lastUsed}</span> },
{ id: 'status', title: 'Статус', size: 200, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.kind}</span> },
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.added}</span> },
{ 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 как альтернатива паролю · пароль остаётся запасным входом"
actions={
<Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey
</Button>
}
/>
{keys.length === 1 ? (
<SignalAlert tone="wn" title="Один passkey — единственная копия.">
<ExplainBanner
tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</SignalAlert>
) : null}
<KpiStatGrid
</ExplainBanner>
<KpiStrip
aria-label="Показатели"
items={[
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: keys.length > 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }),
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockIcon, badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства' }),
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="keys">Passkeys · {keys.length}</TabsTrigger>
<TabsTrigger value="pass">Пароль</TabsTrigger>
<TabsTrigger value="acc">Учётная запись</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTab value="keys" label="Passkeys" count={keys.length} />
<PageTab value="pass" label="Пароль" />
<PageTab value="acc" label="Учётная запись" />
</PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={keys}
rows={view}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
views={[
{ label: 'Все', match: () => true },
{ 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) => {
setSelected(r)
setRenameValue(r.name)
@@ -82,41 +106,20 @@ export function SecurityPage() {
/>
</TabsContent>
<TabsContent value="pass">
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle>Пароль</FrameTitle>
<FrameDescription>Запасной вход, если passkey недоступен</FrameDescription>
</div>
<StatusBadge tone="ok">Задан</StatusBadge>
</FrameHeader>
<FramePanel>
<FieldGroup className="max-w-xl">
<Field>
<FieldLabel htmlFor="pw-cur">Текущий пароль</FieldLabel>
<Input id="pw-cur" type="password" defaultValue="••••••••••" autoComplete="current-password" />
<FieldDescription>Для подтверждения смены · последняя смена 11.03.2026</FieldDescription>
</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>
<FormFrame title="Пароль" description="Запасной вход, если passkey недоступен" badge="Задан">
<SettingRow title="Последняя смена" description="Рекомендуем менять раз в год">
<StatusBadge tone="mu">11.03.2026</StatusBadge>
</SettingRow>
<SettingRow title="Текущий пароль" description="Для подтверждения смены">
<Input type="password" defaultValue="••••••••••" autoComplete="current-password" aria-label="Текущий пароль" className="w-56 max-w-full font-mono" />
</SettingRow>
<SettingRow title="Новый пароль" description="Не короче 12 символов">
<Input type="password" autoComplete="new-password" aria-label="Новый пароль" className="w-56 max-w-full font-mono" />
</SettingRow>
<SettingRow title="Завершить остальные сессии" description="После смены пароля выйти на всех других устройствах">
<Switch defaultChecked aria-label="Завершить остальные сессии" />
</SettingRow>
</FormFrame>
</TabsContent>
<TabsContent value="acc">
<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 { toast } from 'sonner'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { CreateUserPreviewSheet } from '../create-sheets'
import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react'
import { AccessDialog } from '../access-dialog'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { AccessSheet } from '../access-sheet'
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 { ALL_APP_IDS, APP_SHORT, MOCK_USERS, USER_JOURNAL, type Tone } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
import { StatusBadge } from '../bits'
import { ALL_APP_IDS } from '../mock'
import { ExplainBanner } from '../explain-banner'
import { AccessMatrix } from '../access-matrix'
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store'
import { usePatchUsers } from '../user-actions'
export function UsersPage() {
const { addChange } = usePreviewStore()
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
const { addChange, marksFor, users, setUsers } = usePreviewStore()
const marks = marksFor('users')
const usersRef = useRef(users)
useEffect(() => {
usersRef.current = users
}, [users])
const patch = usePatchUsers()
const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [resetFor, setResetFor] = useState<AdminUser | 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 [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [bulkResetIds, setBulkResetIds] = useState<string[] | null>(null)
const onDeactivate = useCallback(
(user: AdminUser) =>
@@ -71,46 +54,39 @@ export function UsersPage() {
last_login_ip: null,
passkey_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
setUsers((prev) => [...prev, created])
addChange({
kind: 'add',
title: `Создан ${created.email}`,
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)
setAccessFor(created)
},
[addChange],
[addChange, setUsers],
)
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 handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess,
onOpenJournal: onOpenAudit,
onToggleDisabled: onDeactivate,
onResetPasskeys,
onDelete,
onBulkDelete: (ids) => setBulkDeleteIds(ids),
onBulkResetPasskeys: (ids) => setBulkResetIds(ids),
onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− 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 noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
const disabled = users.filter((u) => u.disabled).length
@@ -122,43 +98,43 @@ export function UsersPage() {
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
actions={
<Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя
</Button>
}
/>
<KpiStatGrid
<ExplainBanner
tone="er"
title="Подозрительная активность: [email protected]."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner>
<KpiStrip
aria-label="Показатели"
items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }),
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldIcon, tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт' }),
]}
/>
<Tabs defaultValue="users" className="min-w-0">
<TabsList>
<TabsTrigger value="users">Участники · {users.length}</TabsTrigger>
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTab value="users" label="Участники" count={users.length} />
<PageTab value="matrix" label="Доступ по приложениям" count={ALL_APP_IDS.length} />
</PageTabsList>
<TabsContent value="users" className="min-w-0">
<UsersGrid users={users} handlers={handlers} />
<UsersGrid users={users} handlers={handlers} marks={marks} />
</TabsContent>
<TabsContent value="matrix" className="min-w-0">
<PreviewSectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={matrix}
columns={matrixColumns}
getRowId={(r) => r.id}
/>
<AccessMatrix />
</TabsContent>
</Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
<CreateUserPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? (
<AccessDialog
<AccessSheet
key={accessFor.id}
user={accessFor}
onClose={() => setAccessFor(null)}
@@ -174,22 +150,6 @@ export function UsersPage() {
/>
) : 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
open={deleteFor !== null}
@@ -201,52 +161,54 @@ export function UsersPage() {
onConfirm={() => {
const target = deleteFor
if (!target) return
const index = usersRef.current.findIndex((u) => u.id === target.id)
setUsers((prev) => prev.filter((u) => u.id !== target.id))
addChange({
kind: 'del',
title: `Удалён ${target.email}`,
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
revert: () =>
setUsers((prev) => {
const copy = [...prev]
copy.splice(Math.min(index, copy.length), 0, target)
return copy
}),
resource: 'users',
marks: { [target.id]: 'deleted' },
commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id))
})
setDeleteFor(null)
}}
/>
<TypedConfirm
open={resetFor !== null}
onOpenChange={(o) => !o && setResetFor(null)}
title="Сбросить passkeys пользователя?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
open={bulkDeleteIds !== null}
onOpenChange={(o) => !o && setBulkDeleteIds(null)}
title="Удалить выбранных пользователей?"
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="сбросить"
confirmLabel="Сбросить"
onConfirm={() => {
const target = resetFor
if (!target) return
patch([target.id], (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${target.email}`, (b) => [
`− passkeys: ${b.passkey_count}`,
'+ passkeys: 0',
])
toast.warning('Passkeys сброшены в черновике', { description: 'Сохраните изменения, чтобы применить.' })
setResetFor(null)
const ids = bulkResetIds ?? []
patch(ids, (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${ids.length}`, (b) => [`− passkeys: ${b.passkey_count}`, '+ passkeys: 0'])
setBulkResetIds(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 {
ActivityIcon,
ArrowRightIcon,
CheckIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
CloudIcon,
ContrastIcon,
FingerprintIcon,
GlobeIcon,
InfoIcon,
KeyRoundIcon,
ShieldCheckIcon,
LayoutDashboardIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
RowsIcon,
SearchIcon,
ServerIcon,
StarIcon,
ShieldIcon,
SunIcon,
UserPlusIcon,
MoonIcon,
PlayIcon,
PlusIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { Badge } from '@/components/reui/badge'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import {
Breadcrumb,
BreadcrumbItem,
@@ -47,11 +45,9 @@ import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Kbd } from '@authportal/ui/components/kbd'
import { Switch } from '@authportal/ui/components/switch'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { Separator } from '@authportal/ui/components/separator'
import {
@@ -71,7 +67,8 @@ import {
useSidebar,
} from '@authportal/ui/components/sidebar'
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'
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" />}
>
<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 className="grid flex-1 text-left text-sm leading-tight">
<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>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
@@ -136,163 +133,90 @@ function PreviewAppSwitcher() {
)
}
const THEMES = [
{ 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>
)
}
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PreviewNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = usePreviewStore()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label="Профиль: Admin, [email protected]"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
aria-label="Профиль и настройки интерфейса: Admin, [email protected]"
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Admin</span>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
AD
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-64 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-foreground flex items-center gap-2 py-2 font-normal">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/account' })}>
<FingerprintIcon aria-hidden />
Безопасность
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<Segmented
label="Тема"
value={(mounted ? theme : 'system') ?? 'system'}
options={THEMES}
onChange={setTheme}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<RowsIcon aria-hidden />
Плотность
<div className="ml-auto">
<Segmented
label="Плотность"
value={density}
options={[
{ value: 'comfortable', label: 'Обычная' },
{ value: 'compact', label: 'Компактная' },
]}
onChange={setDensity}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<ContrastIcon aria-hidden />
Контраст
<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>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
AD
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">Admin</span>
<span className="text-muted-foreground text-xs">admin@shnt.top</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: '/design-preview/account' })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: '/design-preview/login' })}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
@@ -308,19 +232,21 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<span className="relative flex shrink-0">
<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>
<Icon className="size-4" />
<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>
<SidebarMenuAction
showOnHover={!pinned}
@@ -337,16 +263,17 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
export function PreviewSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = usePreviewStore()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarHeader className="pb-1.5">
<PreviewAppSwitcher />
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
@@ -358,11 +285,23 @@ export function PreviewSidebar() {
) : null}
{NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup>
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<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) => (
<NavEntry
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() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<button
<Button
variant="outline"
type="button"
aria-label={`Монитор Auth Portal: внимание, ${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"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
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" />
<span className="hidden @md/header:inline">Внимание</span>
<span className="bg-destructive inline-flex size-5 items-center justify-center rounded-full text-xs font-semibold text-white tabular-nums">
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<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}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<div className="flex flex-col">
<span className="text-xs font-medium">Монитор Auth Portal</span>
<span className="text-muted-foreground text-[11px]">обновлено 14:02</span>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">обновлено 14:02</span>
</div>
<Badge variant="warning-light" size="sm">Внимание</Badge>
</div>
<div className="text-muted-foreground flex gap-3 border-b px-3 py-2 text-xs">
<span><b className="text-destructive font-semibold">{errors}</b> ошибка</span>
<span><b className="text-warning font-semibold">{warnings}</b> предупреждения</span>
<span><b className="text-success font-semibold">{MONITOR_ITEMS.length - bad.length}</b> в норме</span>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ 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>
<ul className="flex flex-col">
{MONITOR_ITEMS.map((m) => (
<li key={m.id} className="border-border border-b last:border-b-0">
<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">
<span
aria-hidden
className={cn(
'mt-1.5 size-2 shrink-0 rounded-full',
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>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<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 aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</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>
</li>
))}
</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>
</Popover>
)
}
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 (
<Popover>
<PopoverTrigger
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 />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-72 max-w-[calc(100vw-2rem)] flex-col gap-3">
<div className="flex items-center gap-2">
<span className="bg-success size-2 rounded-full" aria-hidden />
<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 align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
@@ -476,7 +443,7 @@ export function PreviewHeader() {
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
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" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
@@ -491,7 +458,7 @@ export function PreviewHeader() {
<Button
type="button"
variant="outline"
size="icon-sm"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
@@ -509,31 +476,85 @@ type PaletteEntry = {
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const PALETTE_USERS = [
{ name: '[email protected]', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: '[email protected]', role: 'Пользователь · 4 приложения', dot: 'er' },
{ name: '[email protected]', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: '[email protected]', role: 'Пользователь · Technitium DNS', dot: 'ok' },
{ name: '[email protected]', 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() {
const { paletteOpen, setPaletteOpen } = usePreviewStore()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
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 q = query.trim().toLowerCase()
return q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries
}, [entries, query])
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
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 map = new Map<string, Array<PaletteEntry & { index: number }>>()
@@ -553,17 +574,17 @@ export function CommandPalette() {
return (
<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>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2 border-b px-3">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
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) => {
setQuery(e.target.value)
setActive(0)
@@ -584,42 +605,44 @@ export function CommandPalette() {
/>
<Kbd>Esc</Kbd>
</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 ? (
<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]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{group}</div>
{list.map((e) => (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn(
'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',
)}
>
{group === 'Действия' ? <UserPlusIcon className="size-4" aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate">{e.label}</span>
<span className="text-muted-foreground truncate text-xs">{e.hint}</span>
</button>
))}
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
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}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</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>Esc закрыть</span>
<span className="ml-auto">Подсказка: «неудачные входы ops за сутки»</span>
</div>
</DialogContent>
</Dialog>
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { toast } from 'sonner'
import type { AdminUser } from '@authportal/shared'
import { MOCK_USERS } from './mock'
import {
PreviewStoreContext,
type Contrast,
type Density,
type PendingChange,
type RowMark,
type PreviewStore,
} from './preview-store'
@@ -15,6 +18,7 @@ import {
export function PreviewProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingChange[]>([])
const [paletteOpen, setPaletteOpen] = useState(false)
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
const [density, setDensity] = useState<Density>('comfortable')
const [contrast, setContrast] = useState<Contrast>('standard')
const [pins, setPins] = useState<string[]>(() => {
@@ -77,20 +81,39 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
const applyAll = useCallback(() => {
const count = pendingRef.current.length
for (const c of pendingRef.current) c.commit?.()
setPending([])
toast.success(`Сохранено изменений: ${count}`, {
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>(
() => ({
pending,
marksFor,
addChange,
discardAll,
applyAll,
paletteOpen,
setPaletteOpen,
users,
setUsers,
pins,
togglePin,
density,
@@ -98,7 +121,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
contrast,
setContrast,
}),
[pending, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast],
[pending, marksFor, addChange, discardAll, applyAll, paletteOpen, users, pins, togglePin, density, contrast],
)
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 RowMark = 'new' | 'dirty' | 'deleted'
export type PendingChange = {
id: string
kind: ChangeKind
title: string
/** Раздел, к которому относятся пометки строк («users», «app-links»). */
resource?: string
/** Пометки строк: «Не сохранено» (dirty/new) и «Будет удалено» (deleted). */
marks?: Record<string, RowMark>
/** Выполняется при «Сохранить»: окончательно применяет правку (например, убирает удалённую строку). */
commit?: () => void
/** Строки diff: «+ …», «− …», « … ». */
lines: string[]
/** Откатывает локальное состояние страницы при «Отменить». */
@@ -17,11 +26,16 @@ export type Contrast = 'standard' | 'high'
export type PreviewStore = {
pending: PendingChange[]
/** Пометки строк раздела из накопленных правок. */
marksFor: (resource: string) => Record<string, RowMark>
addChange: (change: Omit<PendingChange, 'id'>) => void
discardAll: () => void
applyAll: () => void
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
/** Каталог пользователей: общий для списка и профиля. */
users: AdminUser[]
setUsers: Dispatch<SetStateAction<AdminUser[]>>
pins: string[]
togglePin: (id: string) => void
density: Density
+297 -89
View File
@@ -1,5 +1,6 @@
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 { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
@@ -16,20 +17,23 @@ import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
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 { 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 { Segmented } from './bits'
import { StatusBadge } from './bits'
import type { Tone } from './mock'
import type { RowMark } from './preview-store'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
@@ -44,13 +48,40 @@ export type GridColumn<T> = {
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 DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица макета: Frame + Filters-подобная панель + DataGrid на DataGridScrollArea.
* Минимальная ширина таблицы = сумма колонок, поэтому на узком экране таблица
* прокручивается по горизонтали внутри контейнера, а не ломает страницу.
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function PreviewSectionGrid<T extends object>({
title,
@@ -62,11 +93,16 @@ export function PreviewSectionGrid<T extends object>({
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = false,
bulkActions,
toolbar,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 8,
footer,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
@@ -80,11 +116,21 @@ export function PreviewSectionGrid<T extends object>({
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode
toolbar?: ReactNode
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает 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 строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
@@ -109,6 +155,8 @@ export function PreviewSectionGrid<T extends object>({
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
@@ -120,6 +168,8 @@ export function PreviewSectionGrid<T extends object>({
})
}, [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 list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
@@ -146,7 +196,7 @@ export function PreviewSectionGrid<T extends object>({
size: SELECT_COLUMN_SIZE,
})
}
for (const col of columns) {
for (const col of visibleColumns) {
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
@@ -158,8 +208,43 @@ export function PreviewSectionGrid<T extends object>({
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
}, [columns, selectable, onReorder])
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
@@ -180,12 +265,17 @@ export function PreviewSectionGrid<T extends object>({
onReorder(String(active.id), String(over.id))
}
const minWidth = columns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0),
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
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 clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
return (
<DataGrid
@@ -193,81 +283,199 @@ export function PreviewSectionGrid<T extends object>({
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
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)}>
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<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>
<FramePanel className="min-w-0 p-0 shadow-none">
{views || searchText || toolbar ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{views ? (
<Segmented
label="Вид"
options={views.map((v) => `${v.label} · ${rows.filter(v.match).length}`)}
value={view}
onChange={(i) => {
setView(i)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
) : null}
{searchText ? (
<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 />
</>
{views ? (
<Tabs
value={String(view)}
onValueChange={(v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto px-4">
{views.map((v, i) => (
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
{v.label} · {rows.filter(v.match).length}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{selectable && selectedIds.length > 0 && bulkActions ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2">
<span className="text-sm font-medium">Выбрано: {selectedIds.length}</span>
<div className="flex flex-wrap items-center gap-2">
{bulkActions(selectedIds, () => setRowSelection({}))}
<Button type="button" size="sm" variant="ghost" onClick={() => setRowSelection({})}>
Сбросить
</Button>
</div>
</div>
<Separator />
</>
) : null}
<DataGridScrollArea>
<div className="pb-3" style={{ minWidth }}>
{onReorder ? (
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
) : (
<DataGridTable />
)}
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
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>
</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 />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs">
Показано {table.getRowModel().rows.length} из {data.length}
{footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
</span>
{paged ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
{paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
+1 -1
View File
@@ -19,6 +19,6 @@ export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-info',
in: 'bg-success',
mu: 'bg-muted-foreground/50',
}
+3 -3
View File
@@ -37,13 +37,13 @@ export function UnsavedBar() {
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span>
<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
</Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}>
<Button type="button" variant="outline" onClick={discardAll}>
Отменить
</Button>
<Button type="button" size="sm" onClick={applyAll}>
<Button type="button" onClick={applyAll}>
Сохранить
</Button>
</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 {
MoreHorizontalIcon,
PauseCircleIcon,
PlayCircleIcon,
ScrollTextIcon,
ShieldCheckIcon,
Trash2Icon,
FingerprintIcon,
} from 'lucide-react'
import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { StatusBadge } from './bits'
import { PendingChip, StatusBadge } from './bits'
import type { RowMark } from './preview-store'
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
@@ -41,15 +27,15 @@ function passkeyLabel(n: number) {
export type UsersGridHandlers = {
onOpenAccess: (u: AdminUser) => void
onOpenJournal: (u: AdminUser) => void
onToggleDisabled: (u: AdminUser) => void
onResetPasskeys: (u: AdminUser) => void
onDelete: (u: AdminUser) => void
onBulkDisabled: (ids: string[], disabled: boolean) => void
onBulkDelete: (ids: string[]) => void
onBulkResetPasskeys: (ids: string[]) => void
}
/** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */
export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: UsersGridHandlers }) {
/** Каталог пользователей по доске: участник, статус, роль, приложения, passkey, активность · IP. */
export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) {
const columns = useMemo<GridColumn<AdminUser>[]>(
() => [
{
@@ -59,80 +45,72 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U
sortValue: (u) => u.email,
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',
title: '',
size: 56,
id: 'status',
title: 'Статус',
size: 150,
cell: (u) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${u.email}`} />}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem onClick={() => handlers.onOpenAccess(u)}>
<ShieldCheckIcon aria-hidden />
Права доступа
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onOpenJournal(u)}>
<ScrollTextIcon aria-hidden />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlers.onToggleDisabled(u)}>
{u.disabled ? <PlayCircleIcon aria-hidden /> : <PauseCircleIcon aria-hidden />}
{u.disabled ? 'Включить' : 'Отключить'}
</DropdownMenuItem>
<DropdownMenuItem disabled={u.passkey_count === 0} onClick={() => handlers.onResetPasskeys(u)}>
<FingerprintIcon aria-hidden />
Сбросить passkeys…
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handlers.onDelete(u)}>
<Trash2Icon aria-hidden />
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge tone={userStatus(u).tone}>{userStatus(u).label}</StatusBadge>
{marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
</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 (
<PreviewSectionGrid
title="Пользователи портала"
description="Поиск по email и имени · виды по статусу · строка открывает права доступа"
description="Поиск по email и имени · фильтры: роль, статус, приложения, passkey · колонки настраиваются, вид сохраняется"
rows={users}
columns={columns}
getRowId={(u) => u.id}
selectable
searchText={(u) => `${u.email} ${u.name}`}
searchPlaceholder="Email или имя"
views={[
{ 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: 'Отключённые', match: (u) => u.disabled },
]}
onRowClick={handlers.onOpenAccess}
bulkActions={(ids, clear) => (
<>
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, false); clear() }}>
Включить
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => { handlers.onBulkDisabled(ids, true); clear() }}>
Отключить
</Button>
</>
rowStatus={(u) => marks[u.id]}
rowSwitch={{ value: (u) => !u.disabled, onChange: (u) => handlers.onToggleDisabled(u) }}
rowMenu={(u) => ({
onOpen: () => handlers.onOpenAccess(u),
toggleLabel: u.disabled ? 'Включить' : 'Выключить',
onToggle: () => handlers.onToggleDisabled(u),
onDuplicate: () => toast('Дублирование пользователя недоступно в макете'),
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 DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins'
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 DesignPreviewShellUsersIndexRouteImport } from './routes/design-preview._shell.users.index'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -139,16 +139,17 @@ const DesignPreviewShellOidcRoute = DesignPreviewShellOidcRouteImport.update({
path: '/oidc',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellUsersRoute = DesignPreviewShellUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
getParentRoute: () => AuthAdminRoute,
} as any)
const DesignPreviewShellUsersIndexRoute =
DesignPreviewShellUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -169,9 +170,9 @@ export interface FileRoutesByFullPath {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users/': typeof DesignPreviewShellUsersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -190,9 +191,9 @@ export interface FileRoutesByTo {
'/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users': typeof DesignPreviewShellUsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -216,9 +217,9 @@ export interface FileRoutesById {
'/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/_shell/users': typeof DesignPreviewShellUsersRoute
'/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/_shell/users/': typeof DesignPreviewShellUsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -241,9 +242,9 @@ export interface FileRouteTypes {
| '/design-preview/audit'
| '/design-preview/logins'
| '/design-preview/oidc'
| '/design-preview/users'
| '/admin/'
| '/admin/users/$userId'
| '/design-preview/users/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -262,9 +263,9 @@ export interface FileRouteTypes {
| '/design-preview/audit'
| '/design-preview/logins'
| '/design-preview/oidc'
| '/design-preview/users'
| '/admin'
| '/admin/users/$userId'
| '/design-preview/users'
id:
| '__root__'
| '/'
@@ -287,9 +288,9 @@ export interface FileRouteTypes {
| '/design-preview/_shell/audit'
| '/design-preview/_shell/logins'
| '/design-preview/_shell/oidc'
| '/design-preview/_shell/users'
| '/_auth/admin/'
| '/_auth/admin/users/$userId'
| '/design-preview/_shell/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -448,13 +449,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DesignPreviewShellOidcRouteImport
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': {
id: '/_auth/admin/users/$userId'
path: '/users/$userId'
@@ -462,6 +456,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport
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
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
DesignPreviewShellUsersRoute: typeof DesignPreviewShellUsersRoute
DesignPreviewShellUsersIndexRoute: typeof DesignPreviewShellUsersIndexRoute
}
const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
@@ -518,7 +519,7 @@ const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
DesignPreviewShellUsersRoute: DesignPreviewShellUsersRoute,
DesignPreviewShellUsersIndexRoute: DesignPreviewShellUsersIndexRoute,
}
const DesignPreviewShellRouteWithChildren =
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
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,
})