feat(design-preview): перенос досок Auth Portal 1 к 1 — таблицы, вкладки, формы, права
Таблицы по анатомии доски: «Таблица/Карточки», поиск, «Колонки», виды линией со счётчиками, панель массовых действий, переключатель и меню «⋮» в строке. Вкладки страниц — линия с мелким счётчиком. Строки настроек (пароль, ротация журнала, меню), «Объяснить» в баннерах с панелью разбора, палитра с ассистентом и OIDC-клиентами, сворачиваемые группы меню, плашка «Обновлено · Live», данные досок «Изменения» и «Доступ по приложениям», права доступа вкладками. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
709dd93548
commit
afbc6fc366
16 files changed
+991
-818
No files matched your search
@@ -1,20 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRightIcon, ScrollTextIcon } from 'lucide-react'
|
||||
import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { CodeBlock, SignalAlert } from './bits'
|
||||
import { LEVEL_LABEL, grantCount, levelOf, withLevel, type AccessValue, type Level } from './access-model'
|
||||
import { USER_JOURNAL } from './mock'
|
||||
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
|
||||
import { APP_SHORT, USER_JOURNAL } from './mock'
|
||||
|
||||
type Pane = AppId | 'journal'
|
||||
const LEVEL_NAME: Record<Level, string> = { none: 'Нет доступа', read: 'Чтение', write: 'Запись', admin: 'Админ' }
|
||||
|
||||
/** Приложения, у которых на доске есть вкладка с разделами. */
|
||||
const PERM_TABS: AppId[] = ['cfdm', 'vps', 'bgp', 'fw', 'mm']
|
||||
|
||||
/**
|
||||
* Редактор прав: слева приложения (переключатель доступа и счётчик выданных разделов),
|
||||
* справа разделы выбранного приложения с уровнем «Нет / Чтение / Запись / Админ».
|
||||
* Редактор прав по доске «Права доступа»: вкладки «Приложения», по приложению с разделами и «Журнал».
|
||||
* Раздел — строка с названием, подсказкой и уровнем «Нет доступа / Чтение / Запись / Админ».
|
||||
*/
|
||||
export function AccessEditor({
|
||||
user,
|
||||
@@ -25,137 +31,98 @@ export function AccessEditor({
|
||||
value: AccessValue
|
||||
onChange: (next: AccessValue) => void
|
||||
}) {
|
||||
const [pane, setPane] = useState<Pane>(() => value.apps[0] ?? 'cfdm')
|
||||
const catalog = pane === 'journal' ? null : PERMISSION_CATALOG.find((c) => c.appId === pane)
|
||||
const app = pane === 'journal' ? null : APPS.find((a) => a.id === pane)
|
||||
const appOn = pane !== 'journal' && value.apps.includes(pane)
|
||||
|
||||
function setLevel(appId: AppId, sectionId: string, actions: readonly ('read' | 'write' | 'admin')[], level: Level) {
|
||||
onChange({ ...value, permissions: withLevel(value.permissions, appId, sectionId, actions, level) })
|
||||
}
|
||||
|
||||
function setAll(level: Level) {
|
||||
if (!catalog) return
|
||||
let perms = value.permissions
|
||||
for (const s of catalog.sections) {
|
||||
const allowed: Level[] = ['none', ...s.actions]
|
||||
const target: Level = allowed.includes(level) ? level : 'none'
|
||||
perms = withLevel(perms, catalog.appId, s.id, s.actions, target)
|
||||
}
|
||||
onChange({ ...value, permissions: perms })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{user.is_admin ? (
|
||||
<SignalAlert tone="in" title="Администратор">
|
||||
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
|
||||
</SignalAlert>
|
||||
) : null}
|
||||
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[15.5rem_minmax(0,1fr)]">
|
||||
<nav aria-label="Приложения" className="flex flex-col gap-1.5">
|
||||
{APPS.map((a) => {
|
||||
const { granted, total } = grantCount(value.permissions, a.id)
|
||||
const on = value.apps.includes(a.id)
|
||||
const active = pane === a.id
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-2.5 py-2 transition-colors',
|
||||
active ? 'bg-muted border-border' : 'border-transparent hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={on}
|
||||
onCheckedChange={(next) =>
|
||||
onChange({ ...value, apps: next ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
|
||||
}
|
||||
aria-label={`${a.title}: доступ к приложению`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onClick={() => setPane(a.id)}
|
||||
className="focus-visible:ring-ring flex min-w-0 flex-1 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-2"
|
||||
>
|
||||
<span className={cn('min-w-0 flex-1 truncate text-sm font-medium', !on && 'text-muted-foreground')}>
|
||||
{a.title}
|
||||
</span>
|
||||
<Badge variant={on && granted > 0 ? 'info-light' : 'outline'} size="sm" className="shrink-0 tabular-nums">
|
||||
{granted}/{total}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
aria-current={pane === 'journal' ? 'true' : undefined}
|
||||
onClick={() => setPane('journal')}
|
||||
className={cn(
|
||||
'focus-visible:ring-ring mt-1 flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm font-medium outline-none focus-visible:ring-2',
|
||||
pane === 'journal' ? 'bg-muted border-border' : 'text-muted-foreground hover:bg-muted/50 border-transparent',
|
||||
)}
|
||||
>
|
||||
<ScrollTextIcon className="size-4" aria-hidden />
|
||||
<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 border-b">
|
||||
<TabsTrigger value="apps" className="px-1 text-sm after:-bottom-px!">
|
||||
Приложения
|
||||
</TabsTrigger>
|
||||
{PERM_TABS.map((id) => (
|
||||
<TabsTrigger key={id} value={id} className="px-1 text-sm after:-bottom-px!">
|
||||
{id === 'vps' ? 'VPS Tracker' : APP_SHORT[id]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="journal" className="px-1 text-sm after:-bottom-px!">
|
||||
Журнал
|
||||
<ChevronRightIcon className="ml-auto size-4" aria-hidden />
|
||||
</button>
|
||||
</nav>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<section className="flex min-w-0 flex-col gap-3" aria-label={app?.title ?? 'Журнал'}>
|
||||
{pane === 'journal' ? (
|
||||
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
|
||||
) : catalog && app ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h3 className="text-sm font-semibold">{app.title}</h3>
|
||||
<p className="text-muted-foreground text-xs">{app.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => setAll('read')}>
|
||||
Чтение везде
|
||||
</Button>
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => setAll('none')}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!appOn ? (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md border px-3 py-2 text-xs">
|
||||
Приложение выключено слева — права разделов не действуют, пока оно не включено.
|
||||
</p>
|
||||
) : null}
|
||||
<ul className={cn('flex flex-col divide-y rounded-lg border', !appOn && 'opacity-60')}>
|
||||
<TabsContent value="apps" className="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="min-h-0 overflow-y-auto">
|
||||
<ul className="flex flex-col divide-y rounded-lg border">
|
||||
{catalog.sections.map((section) => {
|
||||
const level = levelOf(value.permissions, catalog.appId, section.id, section.actions)
|
||||
const level = levelOf(value.permissions, appId, section.id, section.actions)
|
||||
const options: Level[] = ['none', ...section.actions]
|
||||
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-40 flex-col">
|
||||
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3.5 py-3">
|
||||
<div className="flex min-w-0 flex-1 basis-44 flex-col">
|
||||
<span className="text-sm font-medium">{section.title}</span>
|
||||
<span className="text-muted-foreground text-xs">{section.description}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}
|
||||
</span>
|
||||
</div>
|
||||
<Tabs value={level} onValueChange={(v) => setLevel(catalog.appId, section.id, section.actions, v as Level)}>
|
||||
<TabsList aria-label={`${section.title}: уровень доступа`}>
|
||||
{options.map((o) => (
|
||||
<TabsTrigger key={o} value={o}>
|
||||
{LEVEL_LABEL[o]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<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 size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o} value={o}>
|
||||
{LEVEL_NAME[o]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
|
||||
<TabsContent value="journal" className="flex min-h-0 flex-col gap-3 overflow-y-auto">
|
||||
<p className="text-muted-foreground text-sm">Последние события пользователя из журнала портала.</p>
|
||||
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
import { LockIcon } from 'lucide-react'
|
||||
import { APPS, type AdminUser, type AppId } from '@authportal/shared'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { PendingChip, SignalAlert } from './bits'
|
||||
import { APP_SHORT } from './mock'
|
||||
import type { RowMark } from './preview-store'
|
||||
import { StatusBadge } from './bits'
|
||||
import type { Tone } from './mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
|
||||
|
||||
/**
|
||||
* Матрица доступа (solution-users-3): пользователи в строках, приложения в колонках,
|
||||
* переключатели в ячейках. Администраторам доступ выдан ко всем приложениям — их ячейки заблокированы.
|
||||
*/
|
||||
export function AccessMatrix({
|
||||
users,
|
||||
marks,
|
||||
onToggle,
|
||||
}: {
|
||||
users: AdminUser[]
|
||||
marks: Record<string, RowMark>
|
||||
onToggle: (user: AdminUser, appId: AppId, on: boolean) => void
|
||||
}) {
|
||||
const columns = useMemo<GridColumn<AdminUser>[]>(() => {
|
||||
const granted = (id: AppId) => users.filter((u) => u.is_admin || u.apps.includes(id)).length
|
||||
return [
|
||||
{
|
||||
id: 'user',
|
||||
title: 'Пользователь',
|
||||
size: 260,
|
||||
sortValue: (u) => u.email,
|
||||
cell: (u) => (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<TwoLineCell dot={u.disabled ? 'mu' : 'ok'} title={u.email} sub={u.is_admin ? 'Админ · все приложения' : `${u.apps.length} из ${APPS.length}`} />
|
||||
{marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...APPS.map<GridColumn<AdminUser>>((a) => ({
|
||||
id: a.id,
|
||||
title: `${APP_SHORT[a.id]} · ${granted(a.id)}`,
|
||||
size: 128,
|
||||
cell: (u) =>
|
||||
u.is_admin ? (
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs" title="Администратор: доступ ко всем приложениям">
|
||||
<Switch size="sm" checked disabled aria-label={`${u.email}: ${a.title} (заблокировано)`} />
|
||||
<LockIcon className="size-3" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={u.apps.includes(a.id)}
|
||||
onCheckedChange={(on) => onToggle(u, a.id, on)}
|
||||
aria-label={`${u.email}: ${a.title}`}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
]
|
||||
}, [users, marks, onToggle])
|
||||
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 (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<SignalAlert tone="in" title="Администраторы видят все приложения">
|
||||
Их ячейки заблокированы. Тонкую настройку разделов откройте в правах пользователя.
|
||||
</SignalAlert>
|
||||
<PreviewSectionGrid
|
||||
title="Матрица доступа"
|
||||
description="В заголовке колонки — сколько пользователей имеют доступ к приложению"
|
||||
rows={users}
|
||||
columns={columns}
|
||||
getRowId={(u) => u.id}
|
||||
rowStatus={(u) => marks[u.id]}
|
||||
searchText={(u) => u.email}
|
||||
searchPlaceholder="Email"
|
||||
views={[
|
||||
{ label: 'Все', match: () => true },
|
||||
{ label: 'Пользователи', match: (u) => !u.is_admin },
|
||||
{ label: 'Админы', match: (u) => u.is_admin },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<PreviewSectionGrid
|
||||
title="Матрица доступа"
|
||||
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
|
||||
rows={ROWS}
|
||||
columns={COLUMNS}
|
||||
getRowId={(r) => r.id}
|
||||
views={[{ label: 'Все', match: () => true }]}
|
||||
searchText={(r) => `${r.name} ${r.id}`}
|
||||
bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -343,19 +343,19 @@ 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"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={`Скопировать: ${item.label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
|
||||
toast.success(`${item.label} скопирован`)
|
||||
}}
|
||||
>
|
||||
<CopyIcon aria-hidden />
|
||||
Копировать
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
@@ -421,3 +421,62 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
||||
export function PageTabsList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TabsList variant="line" className="h-10! w-full justify-start gap-6 overflow-x-auto border-b">
|
||||
{children}
|
||||
</TabsList>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||
return (
|
||||
<TabsTrigger value={value} className="px-1 text-sm after:-bottom-px!">
|
||||
{label}
|
||||
{count !== undefined ? <sup className="text-muted-foreground ml-1 text-[10px] font-medium tabular-nums">{count}</sup> : null}
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
|
||||
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
|
||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
|
||||
<b className="text-sm font-medium">{title}</b>
|
||||
<span className="text-muted-foreground text-[13px]">{description}</span>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
|
||||
export function FormFrame({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
tone = 'ok',
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
badge: string
|
||||
tone?: Tone
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{badge}</StatusBadge>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { APP_SHORT } from './mock'
|
||||
import type { Tone } from './mock'
|
||||
import { StatusBadge } from './bits'
|
||||
|
||||
export type AppCardItem = {
|
||||
@@ -86,18 +85,12 @@ export function AppCards({
|
||||
)
|
||||
}
|
||||
|
||||
const TONE: Record<AuditSeverity, { color: string; label: string; tone: 'ok' | 'wn' | 'er' }> = {
|
||||
info: { color: 'bg-success', label: 'Info', tone: 'ok' },
|
||||
warning: { color: 'bg-warning', label: 'Warning', tone: 'wn' },
|
||||
critical: { color: 'bg-destructive', label: 'Critical', tone: 'er' },
|
||||
}
|
||||
|
||||
function when(iso: string) {
|
||||
const d = new Date(iso)
|
||||
const ageH = (Date.now() - d.getTime()) / 3_600_000
|
||||
if (ageH < 24) return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(d)
|
||||
if (ageH < 48) return 'вчера'
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(d)
|
||||
const DOT: Record<Tone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
mu: 'bg-muted-foreground',
|
||||
in: 'bg-chart-4',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,12 +100,12 @@ function when(iso: string) {
|
||||
export function ActivityFeed({
|
||||
title,
|
||||
description,
|
||||
entries,
|
||||
items,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
entries: AuditLogEntry[]
|
||||
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
@@ -126,29 +119,22 @@ export function ActivityFeed({
|
||||
</FrameHeader>
|
||||
<FramePanel className="py-1.5">
|
||||
<ol>
|
||||
{entries.map((e) => {
|
||||
const t = TONE[e.severity]
|
||||
const source = e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app]
|
||||
return (
|
||||
<li key={e.id} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
|
||||
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{when(e.created_at)}</span>
|
||||
<span aria-hidden className="flex h-full flex-col items-center">
|
||||
<i className={cn('mt-1 size-[9px] rounded-full', t.color)} />
|
||||
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
|
||||
{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>
|
||||
<span className="min-w-0">
|
||||
<span className="mr-2 inline-block align-middle">
|
||||
<StatusBadge tone={t.tone}>{t.label}</StatusBadge>
|
||||
</span>
|
||||
<b className="font-medium">{e.summary}</b>
|
||||
<span className="text-muted-foreground mt-0.5 block text-xs">
|
||||
{e.actor_name ?? 'system'} · {source}
|
||||
{e.ip ? ` · ${e.ip}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<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,79 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { CodeBlock, PreviewSheet, SignalAlert, StatusBadge } from './bits'
|
||||
|
||||
/**
|
||||
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
|
||||
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
|
||||
*/
|
||||
export function ExplainBanner({
|
||||
tone,
|
||||
title,
|
||||
children,
|
||||
text,
|
||||
}: {
|
||||
tone: 'er' | 'wn'
|
||||
title: string
|
||||
/** Текст для панели разбора (то же, что в баннере). */
|
||||
text: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<>
|
||||
<SignalAlert
|
||||
tone={tone}
|
||||
title={title}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Объяснить
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SignalAlert>
|
||||
<PreviewSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Ассистент · разбор"
|
||||
description="Черновик: проверьте перед применением"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
void navigate({ to: '/design-preview/logins' })
|
||||
}}
|
||||
>
|
||||
Открыть журнал входов
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SignalAlert tone="in" title="Что случилось">
|
||||
{title} {text}
|
||||
</SignalAlert>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">Вероятные причины</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge tone="er">Подбор пароля</StatusBadge>
|
||||
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
|
||||
<StatusBadge tone="mu">Смена IP</StatusBadge>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
|
||||
</div>
|
||||
<CodeBlock label="Что проверить">
|
||||
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/[email protected]'}
|
||||
</CodeBlock>
|
||||
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
|
||||
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
|
||||
</SignalAlert>
|
||||
</PreviewSheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChartColumnIcon,
|
||||
CloudIcon,
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
LayoutGridIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RotateCcwIcon,
|
||||
ServerIcon,
|
||||
@@ -17,16 +15,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
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 {
|
||||
@@ -38,9 +27,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
|
||||
import { PageHead, PreviewSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '../bits'
|
||||
import { AppCards } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
import { MOCK_SWITCHER } from '../mock'
|
||||
@@ -78,19 +67,6 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
|
||||
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, marksFor } = usePreviewStore()
|
||||
const marks = marksFor('app-links')
|
||||
@@ -170,77 +146,13 @@ export function AppLinksPage() {
|
||||
|
||||
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]">{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)
|
||||
@@ -274,18 +186,17 @@ export function AppLinksPage() {
|
||||
]}
|
||||
/>
|
||||
<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="Название или адрес"
|
||||
@@ -294,53 +205,41 @@ export function AppLinksPage() {
|
||||
{ label: 'В меню', match: (r) => r.enabled },
|
||||
{ label: 'Скрытые', match: (r) => !r.enabled },
|
||||
]}
|
||||
toolbar={
|
||||
tools={
|
||||
<Button type="button" size="sm" variant="outline" onClick={resetOrder}>
|
||||
<RotateCcwIcon aria-hidden />
|
||||
Сбросить порядок
|
||||
</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>
|
||||
<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}» · так увидят пользователи с доступом ко всем приложениям`}
|
||||
|
||||
@@ -14,8 +14,8 @@ import { toast } from 'sonner'
|
||||
import { APPS, type AppId } from '@authportal/shared'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { PageHead } from '../bits'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { PageHead, PageTab, PageTabsList } from '../bits'
|
||||
import { AppCards, type AppCardItem } from '../canvas-blocks'
|
||||
import { kpi } from '../kpi'
|
||||
|
||||
@@ -74,10 +74,10 @@ export function AppsPage() {
|
||||
]}
|
||||
/>
|
||||
<Tabs defaultValue="all" className="min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
|
||||
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
|
||||
</TabsList>
|
||||
<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="Мои приложения"
|
||||
|
||||
@@ -1,47 +1,36 @@
|
||||
import { useState } from 'react'
|
||||
import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react'
|
||||
import { HistoryIcon, NetworkIcon, ShieldAlertIcon, Trash2Icon, UserCogIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
||||
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
|
||||
import { ActivityFeed } from '../canvas-blocks'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { 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)
|
||||
@@ -63,21 +52,21 @@ export function AuditPage() {
|
||||
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('crit', 'Критичных', '3', { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }),
|
||||
kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'портал +5 приложений', sub: 'ingest' }),
|
||||
]}
|
||||
/>
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="feed">Лента</TabsTrigger>
|
||||
<TabsTrigger value="table">Таблица · {MOCK_AUDIT.length}</TabsTrigger>
|
||||
<TabsTrigger value="retention">Ротация</TabsTrigger>
|
||||
</TabsList>
|
||||
<PageTabsList>
|
||||
<PageTab value="feed" label="Лента" />
|
||||
<PageTab value="table" label="Таблица" count={148} />
|
||||
<PageTab value="retention" label="Ротация" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="feed" className="min-w-0">
|
||||
<ActivityFeed
|
||||
title="Лента изменений"
|
||||
description="Последние события · точка отражает важность, плашка — уровень"
|
||||
entries={[...MOCK_AUDIT].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6)}
|
||||
description="Последние события · иконка отражает тип, цвет — важность"
|
||||
items={AUDIT_FEED}
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={() => setTab('table')}>
|
||||
Весь журнал
|
||||
@@ -88,33 +77,40 @@ export function AuditPage() {
|
||||
<TabsContent value="table" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
title="Журнал изменений"
|
||||
description="Поиск по событию, пользователю и IP"
|
||||
rows={MOCK_AUDIT}
|
||||
description="Поиск по событию, пользователю и IP · виды по важности"
|
||||
rows={AUDIT_ROWS}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`}
|
||||
searchPlaceholder="Событие, пользователь, IP"
|
||||
searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
|
||||
views={VIEWS}
|
||||
footer="Показано 8 из 148"
|
||||
onRowClick={setEntry}
|
||||
toolbar={
|
||||
<Button variant="outline" size="sm" onClick={() => toast.success('CSV сформирован (макет)')}>
|
||||
<DownloadIcon aria-hidden />
|
||||
Экспорт CSV
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="retention">
|
||||
<AuditRetentionPanel
|
||||
retentionDays={retention}
|
||||
saving={false}
|
||||
purging={false}
|
||||
onSave={(days) => {
|
||||
setRetention(days)
|
||||
toast.success(`Срок хранения: ${days} дн. (макет)`)
|
||||
}}
|
||||
onPurge={() => toast.success('Старые записи очищены (макет)')}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -127,18 +123,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 { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { 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> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -72,17 +54,13 @@ export function LoginsPage() {
|
||||
</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
|
||||
aria-label="Показатели"
|
||||
@@ -94,17 +72,18 @@ export function LoginsPage() {
|
||||
]}
|
||||
/>
|
||||
<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 +102,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>
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
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,
|
||||
@@ -47,29 +48,10 @@ export function OidcPage() {
|
||||
() => [
|
||||
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> },
|
||||
{ id: '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> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -106,9 +88,13 @@ export function OidcPage() {
|
||||
}
|
||||
/>
|
||||
{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
|
||||
aria-label="Показатели"
|
||||
@@ -120,10 +106,10 @@ export function OidcPage() {
|
||||
]}
|
||||
/>
|
||||
<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,23 @@ export function OidcPage() {
|
||||
{ label: 'Включённые', match: (r) => r.enabled },
|
||||
{ label: 'Выключенные', match: (r) => !r.enabled },
|
||||
]}
|
||||
tools={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden />
|
||||
Обновить секрет…
|
||||
</Button>
|
||||
}
|
||||
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: on } : x))) }}
|
||||
rowMenu={(r) => ({
|
||||
onOpen: () => setEditing(r),
|
||||
toggleLabel: r.enabled ? 'Выключить' : 'Включить',
|
||||
onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x))),
|
||||
})}
|
||||
onRowClick={setEditing}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useMemo, useState } from 'react'
|
||||
import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } 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 { 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> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -45,9 +51,13 @@ export function SecurityPage() {
|
||||
}
|
||||
/>
|
||||
{keys.length === 1 ? (
|
||||
<SignalAlert tone="wn" title="Один passkey — единственная копия.">
|
||||
<ExplainBanner
|
||||
tone="wn"
|
||||
title="Один passkey — единственная копия."
|
||||
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
|
||||
>
|
||||
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
|
||||
</SignalAlert>
|
||||
</ExplainBanner>
|
||||
) : null}
|
||||
<KpiStatGrid
|
||||
aria-label="Показатели"
|
||||
@@ -59,22 +69,38 @@ export function SecurityPage() {
|
||||
]}
|
||||
/>
|
||||
<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}
|
||||
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 +108,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
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } 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 { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||||
import { UserPlusIcon } from 'lucide-react'
|
||||
import { AccessSheet } from '../access-sheet'
|
||||
import { 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, USER_JOURNAL } from '../mock'
|
||||
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'
|
||||
@@ -27,11 +27,10 @@ export function UsersPage() {
|
||||
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 [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
||||
const [bulkResetIds, setBulkResetIds] = useState<string[] | null>(null)
|
||||
|
||||
const onDeactivate = useCallback(
|
||||
(user: AdminUser) =>
|
||||
@@ -74,17 +73,14 @@ export function UsersPage() {
|
||||
)
|
||||
|
||||
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)}`,
|
||||
@@ -108,6 +104,13 @@ export function UsersPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ExplainBanner
|
||||
tone="er"
|
||||
title="Подозрительная активность: [email protected]."
|
||||
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
|
||||
>
|
||||
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
|
||||
</ExplainBanner>
|
||||
<KpiStatGrid
|
||||
aria-label="Показатели"
|
||||
items={[
|
||||
@@ -118,26 +121,15 @@ export function UsersPage() {
|
||||
]}
|
||||
/>
|
||||
<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} marks={marks} />
|
||||
</TabsContent>
|
||||
<TabsContent value="matrix" className="min-w-0">
|
||||
<AccessMatrix
|
||||
users={users}
|
||||
marks={marks}
|
||||
onToggle={(u, appId, on) =>
|
||||
patch(
|
||||
[u.id],
|
||||
(x) => ({ ...x, apps: on ? [...x.apps, appId] : x.apps.filter((id) => id !== appId) }),
|
||||
`${on ? 'Доступ выдан' : 'Доступ снят'}: ${u.email}`,
|
||||
() => [`${on ? '+' : '−'} приложение ${APP_SHORT[appId]}`],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<AccessMatrix />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -160,22 +152,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}
|
||||
@@ -222,23 +198,19 @@ export function UsersPage() {
|
||||
/>
|
||||
|
||||
<TypedConfirm
|
||||
open={resetFor !== null}
|
||||
onOpenChange={(o) => !o && setResetFor(null)}
|
||||
title="Сбросить passkeys пользователя?"
|
||||
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ActivityIcon,
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CloudIcon,
|
||||
FingerprintIcon,
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Segmented } from './bits'
|
||||
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
|
||||
import { APP_SHORT, FRESH_BY_PAGE, MOCK_OIDC_CLIENTS, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
|
||||
import { usePreviewStore } from './preview-store'
|
||||
|
||||
const APP_ICON: Record<AppId, typeof CloudIcon> = {
|
||||
@@ -260,6 +261,7 @@ 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">
|
||||
@@ -283,8 +285,20 @@ export function PreviewSidebar() {
|
||||
<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>
|
||||
<SidebarGroupLabel
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!closed[group]}
|
||||
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||
/>
|
||||
}
|
||||
className="cursor-pointer justify-between"
|
||||
>
|
||||
{group}
|
||||
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.filter((i) => i.group === group).map((item) => (
|
||||
<NavEntry
|
||||
@@ -403,7 +417,9 @@ function MonitorPopover() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -413,21 +429,30 @@ function FreshnessPopover() {
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Плашка «Обновлено · Live · авто» в шапке: видна на широких экранах, как на досках. */
|
||||
function FreshPill() {
|
||||
const [auto, setAuto] = useState(true)
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
className="text-muted-foreground hidden h-[30px] items-center gap-2 rounded-lg border px-2.5 text-xs @[1320px]/header:inline-flex"
|
||||
>
|
||||
<span className="bg-success size-1.5 rounded-full" aria-hidden />
|
||||
Обновлено 14:02 · Live
|
||||
<Switch size="sm" checked={auto} onCheckedChange={setAuto} aria-label="Автообновление" />
|
||||
авто
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function PreviewHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = usePreviewStore()
|
||||
@@ -445,6 +470,7 @@ export function PreviewHeader() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<FreshPill />
|
||||
<FreshnessPopover />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -479,19 +505,34 @@ export function CommandPalette() {
|
||||
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') },
|
||||
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
|
||||
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
|
||||
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
|
||||
...MOCK_OIDC_CLIENTS.map((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: c.clientId, run: go('/design-preview/oidc') })),
|
||||
]
|
||||
}, [navigate])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries
|
||||
}, [entries, query])
|
||||
const found = q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries
|
||||
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||
return [
|
||||
{
|
||||
id: 'ai',
|
||||
group: 'Ассистент · запрос обычным текстом (черновик)',
|
||||
label: `Понял так: «${query.trim()}»`,
|
||||
hint: 'Найдено 6 событий у 2 пользователей — открыть журнал входов с фильтром. Результат нужно проверить.',
|
||||
run: () => void navigate({ to: '/design-preview/logins' }),
|
||||
} as PaletteEntry,
|
||||
...found,
|
||||
]
|
||||
}
|
||||
return found
|
||||
}, [entries, query, navigate])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||
|
||||
@@ -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,19 +17,21 @@ 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'
|
||||
@@ -45,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,
|
||||
@@ -63,12 +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,
|
||||
}: {
|
||||
@@ -82,13 +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
|
||||
}) {
|
||||
@@ -113,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
|
||||
@@ -124,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) {
|
||||
@@ -150,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 ?? (() => ''),
|
||||
@@ -162,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-sm" aria-label="Действия" />}>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
|
||||
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
|
||||
Удалить…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: ACTIONS_COLUMN_SIZE,
|
||||
})
|
||||
return list
|
||||
}, [columns, selectable, onReorder])
|
||||
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
@@ -184,12 +265,14 @@ export function PreviewSectionGrid<T extends object>({
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
const minWidth = columns.reduce(
|
||||
const minWidth = visibleColumns.reduce(
|
||||
(sum, c) => sum + c.size,
|
||||
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0),
|
||||
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
|
||||
)
|
||||
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
|
||||
const clear = () => setRowSelection({})
|
||||
const paged = data.length > pageSize
|
||||
const showCards = Boolean(card) && cards
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
@@ -202,77 +285,193 @@ export function PreviewSectionGrid<T extends object>({
|
||||
tableLayout={{ headerSticky: false, dense: true, 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>
|
||||
<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>
|
||||
<TabsTrigger value="table">Таблица</TabsTrigger>
|
||||
<TabsTrigger value="cards">Карточки</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
{searchText ? (
|
||||
<InputGroup className="w-[220px] max-w-full">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
placeholder="Поиск"
|
||||
aria-label={searchPlaceholder}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{columns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
||||
>
|
||||
<Columns3Icon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-52">
|
||||
{columns.slice(1).map((c) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={c.id}
|
||||
checked={!hidden[c.id]}
|
||||
onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}
|
||||
>
|
||||
{c.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
<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 border-b px-4">
|
||||
{views.map((v, i) => (
|
||||
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px] after:-bottom-px!">
|
||||
{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"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (bulk?.onRun) bulk.onRun(selectedIds)
|
||||
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
|
||||
clear()
|
||||
}}
|
||||
>
|
||||
{bulk?.onLabel ?? 'Включить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (bulk?.offRun) bulk.offRun(selectedIds)
|
||||
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
|
||||
clear()
|
||||
}}
|
||||
>
|
||||
{bulk?.offLabel ?? 'Выключить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (bulk?.onDelete) bulk.onDelete(selectedIds)
|
||||
else toast(`Удаление: ${selectedIds.length} (макет)`)
|
||||
}}
|
||||
>
|
||||
Удалить…
|
||||
</Button>
|
||||
<span className="flex-1" />
|
||||
<Button type="button" size="sm" variant="outline" onClick={clear}>
|
||||
Снять выбор
|
||||
</Button>
|
||||
</div>
|
||||
</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,22 +1,7 @@
|
||||
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 { PendingChip, StatusBadge } from './bits'
|
||||
import type { RowMark } from './preview-store'
|
||||
import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock'
|
||||
@@ -42,15 +27,14 @@ 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, активность. */
|
||||
/** Каталог пользователей по доске: участник, статус, роль, приложения, passkey, активность · IP. */
|
||||
export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record<string, RowMark> }) {
|
||||
const columns = useMemo<GridColumn<AdminUser>[]>(
|
||||
() => [
|
||||
@@ -61,90 +45,72 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
|
||||
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) => (
|
||||
{
|
||||
id: 'status',
|
||||
title: 'Статус',
|
||||
size: 150,
|
||||
cell: (u) => (
|
||||
<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="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,
|
||||
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>
|
||||
),
|
||||
},
|
||||
{ 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] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> },
|
||||
],
|
||||
[handlers, marks],
|
||||
[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}
|
||||
rowStatus={(u) => marks[u.id]}
|
||||
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>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => handlers.onBulkDelete(ids)}>
|
||||
Удалить…
|
||||
</Button>
|
||||
</>
|
||||
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" size="sm" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
|
||||
Сбросить passkeys…
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user