diff --git a/apps/web/src/design-preview/access-editor.tsx b/apps/web/src/design-preview/access-editor.tsx index 0fcd6da..768cfad 100644 --- a/apps/web/src/design-preview/access-editor.tsx +++ b/apps/web/src/design-preview/access-editor.tsx @@ -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 = { 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(() => 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 ( -
+
{user.is_admin ? ( Полный доступ ко всем приложениям и разделам не зависит от настроек ниже. ) : null} -
- + + -
- {pane === 'journal' ? ( - {USER_JOURNAL} - ) : catalog && app ? ( - <> -
-
-

{app.title}

-

{app.description}

-
-
- - -
-
- {!appOn ? ( -

- Приложение выключено слева — права разделов не действуют, пока оно не включено. -

- ) : null} -
    + +
      + {APPS.map((a) => ( +
    • + + + onChange({ ...value, apps: on ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) }) + } + /> +
    • + ))} +
    +
    + + {PERM_TABS.map((appId) => { + const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId) + if (!catalog) return null + return ( + +
      {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 ( -
    • -
      +
    • +
      {section.title} - {section.description} + + {level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'} +
      - setLevel(catalog.appId, section.id, section.actions, v as Level)}> - - {options.map((o) => ( - - {LEVEL_LABEL[o]} - - ))} - - +
    • ) })}
    - - ) : null} -
-
+ + ) + })} + + +

Последние события пользователя из журнала портала.

+ {USER_JOURNAL} +
+
) } diff --git a/apps/web/src/design-preview/access-matrix.tsx b/apps/web/src/design-preview/access-matrix.tsx index 649aba7..3d71e3a 100644 --- a/apps/web/src/design-preview/access-matrix.tsx +++ b/apps/web/src/design-preview/access-matrix.tsx @@ -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 - onToggle: (user: AdminUser, appId: AppId, on: boolean) => void -}) { - const columns = useMemo[]>(() => { - 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) => ( -
- - {marks[u.id] ? : null} -
- ), - }, - ...APPS.map>((a) => ({ - id: a.id, - title: `${APP_SHORT[a.id]} · ${granted(a.id)}`, - size: 128, - cell: (u) => - u.is_admin ? ( - - - - - ) : ( - 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) => {v} + +const COLUMNS: GridColumn[] = [ + { id: 'app', title: 'Приложение', size: 260, sortValue: (r) => r.name, cell: (r) => }, + { id: 'status', title: 'Статус', size: 150, cell: (r) => {r.status} }, + { 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 ( -
- - Их ячейки заблокированы. Тонкую настройку разделов откройте в правах пользователя. - - 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 }, - ]} - /> -
+ r.id} + views={[{ label: 'Все', match: () => true }]} + searchText={(r) => `${r.name} ${r.id}`} + bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }} + /> ) } diff --git a/apps/web/src/design-preview/bits.tsx b/apps/web/src/design-preview/bits.tsx index 3135407..4860301 100644 --- a/apps/web/src/design-preview/bits.tsx +++ b/apps/web/src/design-preview/bits.tsx @@ -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" > - {item.value} - {item.label} + {item.value} + {item.label} ))} @@ -421,3 +421,62 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) { ) } + +/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */ +export function PageTabsList({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { + return ( + + {label} + {count !== undefined ? {count} : null} + + ) +} + +/** Строка настройки как на досках: слева название и описание, справа элемент управления. */ +export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return ( +
+
+ {title} + {description} +
+
{children}
+
+ ) +} + +/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */ +export function FormFrame({ + title, + description, + badge, + tone = 'ok', + children, +}: { + title: string + description: string + badge: string + tone?: Tone + children: ReactNode +}) { + return ( + + +
+ {title} + {description} +
+ {badge} +
+ {children} + + ) +} diff --git a/apps/web/src/design-preview/canvas-blocks.tsx b/apps/web/src/design-preview/canvas-blocks.tsx index 63c2adb..510b75f 100644 --- a/apps/web/src/design-preview/canvas-blocks.tsx +++ b/apps/web/src/design-preview/canvas-blocks.tsx @@ -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 = { - 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 = { + 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({
    - {entries.map((e) => { - const t = TONE[e.severity] - const source = e.source_app === 'portal' ? 'Auth Portal' : APP_SHORT[e.source_app] - return ( -
  1. - {when(e.created_at)} - - - + {items.map((e) => ( +
  2. + {e.t} + + + + + + + {e.label} - - - {t.label} - - {e.summary} - - {e.actor_name ?? 'system'} · {source} - {e.ip ? ` · ${e.ip}` : ''} - - -
  3. - ) - })} + {e.title} + {e.text} + + + ))}
diff --git a/apps/web/src/design-preview/explain-banner.tsx b/apps/web/src/design-preview/explain-banner.tsx new file mode 100644 index 0000000..d4c0306 --- /dev/null +++ b/apps/web/src/design-preview/explain-banner.tsx @@ -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 ( + <> + setOpen(true)}> + Объяснить + + } + > + {children} + + + + + + } + > + + {title} {text} + +
+ Вероятные причины +
+ Подбор пароля + Устаревший passkey + Смена IP +
+ Отсортированы по вероятности по журналу входов за 24 часа. +
+ + {'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/sessions?user=ops@shnt.top'} + + + Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения». + +
+ + ) +} diff --git a/apps/web/src/design-preview/mock.ts b/apps/web/src/design-preview/mock.ts index b510442..af1e998 100644 --- a/apps/web/src/design-preview/mock.ts +++ b/apps/web/src/design-preview/mock.ts @@ -151,7 +151,7 @@ export const MOCK_USERS: AdminUser[] = [ 'ops@shnt.top', '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 = { '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 = { + apps: { badge: 'Портал', text: 'Получено 14:02:11 · сессия действует до 22:02 · права из базы портала, не из токена' }, + account: { badge: 'WebAuthn', text: 'Получено 14:02:11 · браузер поддерживает passkeys · домен auth.shnt.top' }, + users: { badge: 'Портал', text: 'Получено 14:02:11 · GET /api/v1/admin/users · 7 учётных записей' }, + logins: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=logins · хранение 90 дней' }, + audit: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=changes · ingest от 5 приложений' }, + oidc: { badge: 'OIDC', text: 'Получено 14:02:11 · discovery и JWKS доступны · подпись RS256' }, + 'app-links': { badge: 'Портал', text: 'Получено 14:02:11 · portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' }, +} + +export type AuditRow = { + id: string + action: string + summary: string + tone: Tone + label: string + source: string + who: string + ip: string + when: string +} + +/** Таблица журнала с доски «Изменения» (значения те же, что на доске). */ +export const AUDIT_ROWS: AuditRow[] = [ + { id: 'e1', action: 'user.update', summary: 'Отключён former@shnt.top', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '14:02' }, + { id: 'e2', action: 'user.access_update', summary: 'ops@shnt.top · +EvoFirewall:agents:write', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '13:50' }, + { id: 'e3', action: 'fw.policy_apply', summary: 'db-01 · поколение 128 · ошибка nft', tone: 'er', label: 'Critical', source: 'EvoFirewall', who: 'system', ip: '—', when: '12:31' }, + { id: 'e4', action: 'oidc.client_update', summary: 'Technitium DNS · новый секрет', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '09:12' }, + { id: 'e5', action: 'app_switcher.update', summary: '+CDN Manager в меню приложений', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: 'вчера 18:20' }, + { id: 'e6', action: 'mm.server_delete', summary: 'Удалён сервер old-edge', tone: 'wn', label: 'Warning', source: 'MikrotikManager', who: 'denis', ip: '198.51.100.4', when: 'вчера 15:02' }, + { id: 'e7', action: 'user.create', summary: 'Создан pavel@shnt.top', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '19.06' }, + { id: 'e8', action: 'audit.purge', summary: 'Очищено 212 записей старше 90 дн.', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '01.10' }, +] + +/** Лента с доски «Изменения». */ +export const AUDIT_FEED: Array<{ t: string; title: string; text: string; label: string; tone: Tone }> = [ + { t: '14:02', title: 'Отключён пользователь former@shnt.top', text: 'admin · Auth Portal · 203.0.113.10', label: 'Warning', tone: 'wn' }, + { t: '13:50', title: 'Права обновлены: ops@shnt.top', text: '+EvoFirewall:agents:write · admin · Auth Portal', label: 'Info', tone: 'ok' }, + { t: '12:31', title: 'Применена политика firewall (поколение 128)', text: 'system · EvoFirewall · db-01 — ошибка применения', label: 'Critical', tone: 'er' }, + { t: '09:12', title: 'Обновлён OIDC-клиент Technitium DNS', text: 'новый client_secret · admin · Auth Portal', label: 'Info', tone: 'ok' }, + { t: 'вчера', title: 'Изменён список приложений в переключателе', text: '+CDN Manager · admin · Auth Portal', label: 'Info', tone: 'in' }, +] diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx index 9f77fa5..ca9d69d 100644 --- a/apps/web/src/design-preview/pages/app-links-page.tsx +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -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 ( -
-
- {title} - {description} -
-
{children}
-
- ) -} - export function AppLinksPage() { const { addChange, marksFor } = usePreviewStore() const marks = marksFor('app-links') @@ -170,77 +146,13 @@ export function AppLinksPage() { const columns = useMemo[]>( () => [ - { - id: 'order', - title: '№', - size: 52, - cell: (r) => ( - - {rows.findIndex((x) => x.id === r.id) + 1} - - ), - }, - { - id: 'name', - title: 'Приложение', - size: 280, - cell: (r) => { - const { Icon } = ICONS[r.icon] - return ( -
- - - - -
- ) - }, - }, - { - id: 'menu', - title: 'В меню', - size: 170, - cell: (r) => ( -
e.stopPropagation()}> - setEnabled([r.id], on)} - aria-label={`${r.name}: показывать в меню`} - /> - {r.enabled ? 'В меню' : 'Скрыто'} -
- ), - }, - { id: 'url', title: 'URL', size: 260, cell: (r) => {r.url} }, - { id: 'mode', title: 'Вход', size: 100, cell: (r) => {r.mode} }, - { - id: 'act', - title: '', - size: 56, - cell: (r) => ( -
e.stopPropagation()}> - - }> - - - - setEditing(r)}> - - Настроить - - - moveTo(r.id, () => 0)}>В начало - moveTo(r.id, (i) => i - 1)}>Выше - moveTo(r.id, (i) => i + 1)}>Ниже - moveTo(r.id, (_i, n) => n - 1)}>В конец - - -
- ), - }, + { id: 'name', title: 'Приложение', size: 260, cell: (r) => }, + { id: 'status', title: 'Статус', size: 130, cell: (r) => {r.enabled ? 'В меню' : 'Скрыто'} }, + { id: 'icon', title: 'Иконка', size: 120, cell: (r) => {r.icon} }, + { id: 'url', title: 'URL', size: 260, cell: (r) => {r.url} }, + { id: 'mode', title: 'Вход', size: 90, cell: (r) => {r.mode} }, ], - [rows, setEnabled, moveTo], + [], ) const shown = rows.filter((r) => r.enabled) @@ -274,18 +186,17 @@ export function AppLinksPage() { ]} /> - - Приложения · {rows.length} - Меню - + + + + 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={ } - bulkActions={(ids, clear) => ( - <> - - - - )} + 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} /> - - -
- Переключатель приложений - Как выглядит меню в шапке каждого приложения -
- Опубликовано -
- - - setMenuLabel(e.target.value)} - aria-label="Заголовок меню" - className="w-56 max-w-full" - /> - - - - - - - - - + + + setMenuLabel(e.target.value)} aria-label="Заголовок меню" className="w-56 max-w-full" /> + + + + + + + + - - Доступные · {allowed.length} - Без доступа · {denied.length} - + + + + = { info: 'ok', warning: 'wn', critical: 'er' } -const SEVERITY_LABEL: Record = { info: 'Info', warning: 'Warning', critical: 'Critical' } +const mono = (v: string) => {v} -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[] = [ - { id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => {r.action}} sub={r.summary} /> }, - { id: 'sev', title: 'Важность', size: 120, sortValue: (r) => r.severity, cell: (r) => {SEVERITY_LABEL[r.severity]} }, - { id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source_app, cell: (r) => {sourceLabel(r)} }, - { id: 'who', title: 'Кто', size: 140, cell: (r) => {r.actor_name ?? '—'} }, - { id: 'ip', title: 'IP', size: 140, cell: (r) => {r.ip ?? '—'} }, - { id: 'when', title: 'Когда', size: 140, sortValue: (r) => r.created_at, cell: (r) => {whenLabel(r.created_at)} }, +const columns: GridColumn[] = [ + { id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => {r.action}} sub={r.summary} /> }, + { id: 'status', title: 'Статус', size: 120, cell: (r) => {r.label} }, + { 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(null) + const [entry, setEntry] = useState(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' }), ]} /> - - Лента - Таблица · {MOCK_AUDIT.length} - Ротация - + + + + + b.created_at.localeCompare(a.created_at)).slice(0, 6)} + description="Последние события · иконка отражает тип, цвет — важность" + items={AUDIT_FEED} action={ - } /> - { - setRetention(days) - toast.success(`Срок хранения: ${days} дн. (макет)`) - }} - onPurge={() => toast.success('Старые записи очищены (макет)')} - /> + + + { + const n = Number.parseInt(e.target.value, 10) + if (Number.isFinite(n) && n >= 7 && n <= 3650 && n !== retention) { + setRetention(n) + toast.success(`Срок хранения: ${n} дн. (макет)`) + } + }} + /> + + + + + + 1 482 + + @@ -127,18 +123,18 @@ export function AuditPage() { > {entry ? ( <> +
- {SEVERITY_LABEL[entry.severity]} + {entry.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 }'} ) : null} diff --git a/apps/web/src/design-preview/pages/logins-page.tsx b/apps/web/src/design-preview/pages/logins-page.tsx index 6bb1c41..5c5c003 100644 --- a/apps/web/src/design-preview/pages/logins-page.tsx +++ b/apps/web/src/design-preview/pages/logins-page.tsx @@ -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[]>( () => [ { id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => }, - { id: 'status', title: 'Результат', size: 120, cell: (r) => {r.status} }, - { id: 'time', title: 'Время', size: 130, cell: (r) => {r.time} }, - { id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => {r.ip} }, - { id: 'app', title: 'Приложение', size: 160, cell: (r) => {r.app} }, - { id: 'reason', title: 'Причина', size: 200, cell: (r) => {r.reason} }, + { id: 'status', title: 'Статус', size: 120, cell: (r) => {r.status} }, + { id: 'time', title: 'Время', size: 130, cell: (r) => {r.time} }, + { id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => {r.ip} }, + { id: 'app', title: 'Приложение', size: 160, cell: (r) => {r.app} }, + { id: 'reason', title: 'Причина', size: 200, cell: (r) => {r.reason} }, ], [], ) @@ -34,28 +35,9 @@ export function LoginsPage() { () => [ { id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => }, { id: 'status', title: 'Статус', size: 110, cell: (r) => {r.status} }, - { id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => {r.ipClient} }, - { id: 'created', title: 'Создана', size: 140, cell: (r) => {r.created} }, - { id: 'exp', title: 'Истекает', size: 120, cell: (r) => {r.expires} }, - { - id: 'act', - title: '', - size: 130, - cell: (r) => ( - - ), - }, + { id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => {r.ipClient} }, + { id: 'created', title: 'Создана', size: 140, cell: (r) => {r.created} }, + { id: 'exp', title: 'Истекает', size: 120, cell: (r) => {r.expires} }, ], [], ) @@ -72,17 +54,13 @@ export function LoginsPage() { } /> - } nativeButton={false}> - К пользователям - - } + text="6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи»." > - 6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи». - + 6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи». + - - История · 44 - Активные сессии · {sessions.length} - + + + + 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} завершена (макет)`) + }, + })} /> diff --git a/apps/web/src/design-preview/pages/oidc-page.tsx b/apps/web/src/design-preview/pages/oidc-page.tsx index 82769a7..e6ba347 100644 --- a/apps/web/src/design-preview/pages/oidc-page.tsx +++ b/apps/web/src/design-preview/pages/oidc-page.tsx @@ -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) => }, { id: 'status', title: 'Статус', size: 150, cell: (r) => {statusLabel(r)} }, - { id: 'cid', title: 'client_id', size: 150, cell: (r) => {r.clientId} }, - { id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => {r.redirect} }, - { id: 'secret', title: 'Секрет', size: 140, cell: (r) => {r.secret} }, - { id: 'created', title: 'Создан', size: 120, cell: (r) => {r.created} }, - { - id: 'act', - title: '', - size: 170, - cell: (r) => ( - - ), - }, + { id: 'cid', title: 'client_id', size: 150, cell: (r) => {r.clientId} }, + { id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => {r.redirect} }, + { id: 'secret', title: 'Секрет', size: 140, cell: (r) => {r.secret} }, + { id: 'created', title: 'Создан', size: 120, cell: (r) => {r.created} }, ], [], ) @@ -106,9 +88,13 @@ export function OidcPage() { } /> {staleCount > 0 ? ( - + Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления. - + ) : null} - - Клиенты · {clients.length} - Параметры issuer - + + + + r.enabled }, { label: 'Выключенные', match: (r) => !r.enabled }, ]} + tools={ + + } + 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} /> diff --git a/apps/web/src/design-preview/pages/security-page.tsx b/apps/web/src/design-preview/pages/security-page.tsx index 098bb11..f6ab26c 100644 --- a/apps/web/src/design-preview/pages/security-page.tsx +++ b/apps/web/src/design-preview/pages/security-page.tsx @@ -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(null) + const [revoked, setRevoked] = useState([]) + const view = useMemo( + () => keys.map((k) => (revoked.includes(k.id) ? { ...k, tone: 'mu' as const, status: 'Отозван' } : k)), + [keys, revoked], + ) + const columns = useMemo[]>( () => [ { id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => }, - { id: 'kind', title: 'Тип', size: 160, cell: (r) => {r.kind} }, - { id: 'added', title: 'Добавлен', size: 120, cell: (r) => {r.added} }, - { id: 'last', title: 'Последний вход', size: 160, cell: (r) => {r.lastUsed} }, - { id: 'status', title: 'Статус', size: 200, cell: (r) => {r.status} }, + { id: 'status', title: 'Статус', size: 210, cell: (r) => {r.status} }, + { id: 'kind', title: 'Тип', size: 160, cell: (r) => {r.kind} }, + { id: 'added', title: 'Добавлен', size: 120, cell: (r) => {r.added} }, + { id: 'last', title: 'Последний вход', size: 160, cell: (r) => {r.lastUsed} }, ], [], ) @@ -45,9 +51,13 @@ export function SecurityPage() { } /> {keys.length === 1 ? ( - + Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. - + ) : null} - - Passkeys · {keys.length} - Пароль - Учётная запись - + + + + + 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() { /> - - -
- Пароль - Запасной вход, если passkey недоступен -
- Задан -
- - - - Текущий пароль - - Для подтверждения смены · последняя смена 11.03.2026 - - - Новый пароль - - Не короче 12 символов - - - -
- Завершить остальные сессии - После смены пароля выйти на всех других устройствах -
-
-
- -
-
-
- + + + 11.03.2026 + + + + + + + + + + +
(null) const [accessFor, setAccessFor] = useState(null) const [deleteFor, setDeleteFor] = useState(null) - const [resetFor, setResetFor] = useState(null) const [bulkDeleteIds, setBulkDeleteIds] = useState(null) + const [bulkResetIds, setBulkResetIds] = useState(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() { } /> + + 6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать». + - - Участники · {users.length} - Доступ по приложениям · {ALL_APP_IDS.length} - + + + + - - patch( - [u.id], - (x) => ({ ...x, apps: on ? [...x.apps, appId] : x.apps.filter((id) => id !== appId) }), - `${on ? 'Доступ выдан' : 'Доступ снят'}: ${u.email}`, - () => [`${on ? '+' : '−'} приложение ${APP_SHORT[appId]}`], - ) - } - /> + @@ -160,22 +152,6 @@ export function UsersPage() { /> ) : null} - !o && setJournalFor(null)} - title="Журнал пользователя" - description="Последние события пользователя из журнала портала" - footer={} - > - - {USER_JOURNAL} - !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) }} /> + ) } diff --git a/apps/web/src/design-preview/preview-chrome.tsx b/apps/web/src/design-preview/preview-chrome.tsx index 26dadb1..6d2571a 100644 --- a/apps/web/src/design-preview/preview-chrome.tsx +++ b/apps/web/src/design-preview/preview-chrome.tsx @@ -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 = { @@ -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>({}) const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id)) return ( @@ -283,8 +285,20 @@ export function PreviewSidebar() {
{gi > 0 ? : null} - {group} - + setClosed((c) => ({ ...c, [group]: !c[group] }))} + /> + } + className="cursor-pointer justify-between" + > + {group} + + + {NAV_ITEMS.filter((i) => i.group === group).map((item) => ( st.location.pathname }) + const key = pathname.split('/')[2] ?? 'apps' + const fresh = FRESH_BY_PAGE[key] ?? FRESH_BY_PAGE.apps! return ( - -
- - Обновлено 14:02:11 · Live -
-

Данные портала: GET /api/v1/admin/* · сессия и права из базы портала. В макете запросы не отправляются.

- + + {fresh.badge} +

{fresh.text}

) } +/** Плашка «Обновлено · Live · авто» в шапке: видна на широких экранах, как на досках. */ +function FreshPill() { + const [auto, setAuto] = useState(true) + return ( + + + Обновлено 14:02 · Live + + авто + + ) +} + export function PreviewHeader() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const { setPaletteOpen } = usePreviewStore() @@ -445,6 +470,7 @@ export function PreviewHeader() {
+ -
-
- - - ) : null} - -
- {onReorder ? ( - - ) : ( - - )} + {selectable && selectedIds.length > 0 ? ( +
+ Выбрано: {selectedIds.length} + + + + +
- + ) : null} + {showCards && card ? ( +
    + {data.map((r) => { + const c = card(r) + const id = getRowId(r) + const marked = rowStatus?.(r) + return ( +
  • +
    + + setRowSelection((s) => { + const next = { ...s } + if (e.target.checked) next[id] = true + else delete next[id] + return next + }) + } + className="accent-primary size-4" + /> + {c.status} +
    + +
    + {c.cells.map((x) => ( +
    +
    {x.l}
    +
    {x.v}
    +
    + ))} +
    +
  • + ) + })} +
+ ) : ( + +
+ {onReorder ? ( + + ) : ( + + )} +
+
+ )} - Показано {table.getRowModel().rows.length} из {data.length} + {footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`} - {paged ? : null} + {paged && !showCards ? : null} diff --git a/apps/web/src/design-preview/users-grid.tsx b/apps/web/src/design-preview/users-grid.tsx index abcc677..3eca95f 100644 --- a/apps/web/src/design-preview/users-grid.tsx +++ b/apps/web/src/design-preview/users-grid.tsx @@ -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 }) { const columns = useMemo[]>( () => [ @@ -61,90 +45,72 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand sortValue: (u) => u.email, cell: (u) => , }, - { id: 'status', title: 'Статус', size: 140, cell: (u) => ( + { + id: 'status', + title: 'Статус', + size: 150, + cell: (u) => (
{userStatus(u).label} {marks[u.id] ? : null}
), }, - { id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => {u.is_admin ? 'Админ' : 'Пользователь'} }, - { id: 'apps', title: 'Приложения', size: 220, cell: (u) => {appsSummary(u)} }, - { id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => {passkeyLabel(u.passkey_count)} }, - { id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => {USER_META[u.id]?.activity ?? '—'} }, - { - id: 'menu', - title: '', - size: 56, - cell: (u) => ( -
e.stopPropagation()}> - - } - > - - - - handlers.onOpenAccess(u)}> - - Права доступа - - handlers.onOpenJournal(u)}> - - Журнал - - handlers.onToggleDisabled(u)}> - {u.disabled ? : } - {u.disabled ? 'Включить' : 'Отключить'} - - handlers.onResetPasskeys(u)}> - - Сбросить passkeys… - - - handlers.onDelete(u)}> - - Удалить… - - - -
- ), - }, + { id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => {u.is_admin ? 'Админ' : 'Пользователь'} }, + { id: 'apps', title: 'Приложения', size: 220, cell: (u) => {appsSummary(u)} }, + { id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => {passkeyLabel(u.passkey_count)} }, + { id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => {USER_META[u.id]?.activity ?? '—'} }, ], - [handlers, marks], + [marks], ) return ( 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) => ( - <> - - - - + 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) => ( + )} /> )