From 157396613d25460339188f27d143e3125d833042 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 17 Oct 2026 22:05:52 +0700 Subject: [PATCH] =?UTF-8?q?feat(design-preview):=20=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B0=D0=BB=D0=B0=20=D0=BD=D0=B0=20ReUI=20PRO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена ветка маршрутов /design-preview без авторизации и без запросов к API: вход (вне оболочки), приложения, безопасность (passkeys), пользователи (права доступа по приложениям и разделам, создание, массовые действия, удаление вводом email, сброс passkeys), входы (история и сессии), изменения (лента Timeline, таблица, ротация журнала), OIDC-клиенты (issuer, создание, секрет один раз), ссылки приложений. Оболочка: сайдбар с группами «Портал» и «Админ», переключатель на 8 приложений, свёрнутый вид с точками проблем, шапка с крошками и монитором, меню пользователя с темой/плотностью/контрастом, палитра ⌘K, панель «Не сохранено» с diff. Данные — суррогатные, названия из @authportal/shared. В audit-log-timeline добавлены min-w-0, чтобы лента не вызывала горизонтальный скролл на узких экранах. Co-Authored-By: Claude Sonnet 5.5 --- .../reui-kit/audit-log-timeline.tsx | 12 +- apps/web/src/design-preview/access-dialog.tsx | 197 ++++++ apps/web/src/design-preview/access-model.ts | 13 + apps/web/src/design-preview/bits.tsx | 369 ++++++++++++ .../web/src/design-preview/design-preview.css | 14 + apps/web/src/design-preview/mock.ts | 438 ++++++++++++++ .../design-preview/pages/app-links-page.tsx | 243 ++++++++ .../src/design-preview/pages/apps-page.tsx | 105 ++++ .../src/design-preview/pages/audit-page.tsx | 189 ++++++ .../src/design-preview/pages/login-page.tsx | 150 +++++ .../src/design-preview/pages/logins-page.tsx | 191 ++++++ .../src/design-preview/pages/oidc-page.tsx | 254 ++++++++ .../design-preview/pages/security-page.tsx | 240 ++++++++ .../src/design-preview/pages/users-page.tsx | 239 ++++++++ .../web/src/design-preview/preview-chrome.tsx | 564 ++++++++++++++++++ .../src/design-preview/preview-provider.tsx | 88 +++ apps/web/src/design-preview/preview-shell.tsx | 44 ++ apps/web/src/design-preview/preview-store.ts | 37 ++ apps/web/src/design-preview/section-grid.tsx | 255 ++++++++ apps/web/src/design-preview/tone.ts | 24 + apps/web/src/design-preview/unsaved-bar.tsx | 92 +++ apps/web/src/routeTree.gen.ts | 246 ++++++++ .../routes/design-preview._shell.account.tsx | 6 + .../design-preview._shell.app-links.tsx | 6 + .../src/routes/design-preview._shell.apps.tsx | 6 + .../routes/design-preview._shell.audit.tsx | 6 + .../routes/design-preview._shell.logins.tsx | 6 + .../src/routes/design-preview._shell.oidc.tsx | 6 + apps/web/src/routes/design-preview._shell.tsx | 14 + .../routes/design-preview._shell.users.tsx | 6 + apps/web/src/routes/design-preview.index.tsx | 7 + apps/web/src/routes/design-preview.login.tsx | 6 + apps/web/src/routes/design-preview.tsx | 15 + 33 files changed, 4082 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/design-preview/access-dialog.tsx create mode 100644 apps/web/src/design-preview/access-model.ts create mode 100644 apps/web/src/design-preview/bits.tsx create mode 100644 apps/web/src/design-preview/design-preview.css create mode 100644 apps/web/src/design-preview/mock.ts create mode 100644 apps/web/src/design-preview/pages/app-links-page.tsx create mode 100644 apps/web/src/design-preview/pages/apps-page.tsx create mode 100644 apps/web/src/design-preview/pages/audit-page.tsx create mode 100644 apps/web/src/design-preview/pages/login-page.tsx create mode 100644 apps/web/src/design-preview/pages/logins-page.tsx create mode 100644 apps/web/src/design-preview/pages/oidc-page.tsx create mode 100644 apps/web/src/design-preview/pages/security-page.tsx create mode 100644 apps/web/src/design-preview/pages/users-page.tsx create mode 100644 apps/web/src/design-preview/preview-chrome.tsx create mode 100644 apps/web/src/design-preview/preview-provider.tsx create mode 100644 apps/web/src/design-preview/preview-shell.tsx create mode 100644 apps/web/src/design-preview/preview-store.ts create mode 100644 apps/web/src/design-preview/section-grid.tsx create mode 100644 apps/web/src/design-preview/tone.ts create mode 100644 apps/web/src/design-preview/unsaved-bar.tsx create mode 100644 apps/web/src/routes/design-preview._shell.account.tsx create mode 100644 apps/web/src/routes/design-preview._shell.app-links.tsx create mode 100644 apps/web/src/routes/design-preview._shell.apps.tsx create mode 100644 apps/web/src/routes/design-preview._shell.audit.tsx create mode 100644 apps/web/src/routes/design-preview._shell.logins.tsx create mode 100644 apps/web/src/routes/design-preview._shell.oidc.tsx create mode 100644 apps/web/src/routes/design-preview._shell.tsx create mode 100644 apps/web/src/routes/design-preview._shell.users.tsx create mode 100644 apps/web/src/routes/design-preview.index.tsx create mode 100644 apps/web/src/routes/design-preview.login.tsx create mode 100644 apps/web/src/routes/design-preview.tsx diff --git a/apps/web/src/components/reui-kit/audit-log-timeline.tsx b/apps/web/src/components/reui-kit/audit-log-timeline.tsx index 94c5ed2..8f2ca90 100644 --- a/apps/web/src/components/reui-kit/audit-log-timeline.tsx +++ b/apps/web/src/components/reui-kit/audit-log-timeline.tsx @@ -116,7 +116,7 @@ function EventRow({ const reason = detailString(event.details, 'reason') return ( - +
@@ -145,8 +145,8 @@ function EventRow({ - - + + - +
@@ -327,7 +327,7 @@ export function AuditLogTimeline({

-
+
v && setLevel(catalog.appId, section.id, section.actions, v as Level)} + items={options.map((o) => ({ value: o, label: LEVEL_LABEL[o] }))} + > + + + + + + {options.map((o) => ( + + {LEVEL_LABEL[o]} + + ))} + + + + + ) + })} + + + ))} + + + {USER_JOURNAL} + + + + + {lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'} + + + + + + + ) +} diff --git a/apps/web/src/design-preview/access-model.ts b/apps/web/src/design-preview/access-model.ts new file mode 100644 index 0000000..c445272 --- /dev/null +++ b/apps/web/src/design-preview/access-model.ts @@ -0,0 +1,13 @@ +import type { AppId } from '@authportal/shared' +import { APP_SHORT } from './mock' + +export type AccessValue = { apps: AppId[]; permissions: string[] } + +export function diffAccess(before: AccessValue, after: AccessValue): string[] { + const lines: string[] = [] + for (const id of after.apps) if (!before.apps.includes(id)) lines.push(`+ приложение ${APP_SHORT[id]}`) + for (const id of before.apps) if (!after.apps.includes(id)) lines.push(`− приложение ${APP_SHORT[id]}`) + for (const key of after.permissions) if (!before.permissions.includes(key)) lines.push(`+ ${key}`) + for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`) + return lines +} diff --git a/apps/web/src/design-preview/bits.tsx b/apps/web/src/design-preview/bits.tsx new file mode 100644 index 0000000..ee6eeb3 --- /dev/null +++ b/apps/web/src/design-preview/bits.tsx @@ -0,0 +1,369 @@ +import { useState, type ReactNode } from 'react' +import { CircleAlertIcon, CopyIcon, 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' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@authportal/ui/components/alert-dialog' +import { Button } from '@authportal/ui/components/button' +import { Input } from '@authportal/ui/components/input' +import { Label } from '@authportal/ui/components/label' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@authportal/ui/components/sheet' +import type { Tone } from './mock' +import { TONE_BADGE } from './tone' + +export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) { + return ( + + {children} + + ) +} + +/** Заголовок страницы: название, описание, метка свежести и действия. */ +export function PageHead({ + title, + sub, + source, + actions, +}: { + title: string + sub: string + source: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+

{sub}

+

+ + Получено 14:02:11 · {source} +

+
+ {actions ?
{actions}
: null} +
+ ) +} + +const ALERT_ICON = { + er: , + wn: , + in: , +} as const + +const ALERT_VARIANT = { + er: 'destructive', + wn: 'warning', + in: 'info', +} as const + +export function SignalAlert({ + tone, + title, + children, + action, +}: { + tone: 'er' | 'wn' | 'in' + title: string + children: ReactNode + action?: ReactNode +}) { + return ( + + {ALERT_ICON[tone]} + {title} + {children} + {action ? {action} : null} + + ) +} + +/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */ +export function PreviewSheet({ + open, + onOpenChange, + title, + description, + children, + footer, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + children: ReactNode + footer?: ReactNode +}) { + return ( + + + +
+ {title} + + } + > + +
+ + {description} + +
+
{children}
+ {footer ? ( + {footer} + ) : null} +
+
+ ) +} + +/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */ +export function TypedConfirm({ + open, + onOpenChange, + title, + description, + expected, + confirmLabel, + onConfirm, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: ReactNode + expected: string + confirmLabel: string + onConfirm: () => void +}) { + const [value, setValue] = useState('') + const matches = value.trim().toLowerCase() === expected.toLowerCase() + + return ( + { + if (!next) setValue('') + onOpenChange(next) + }} + > + + + {title} + {description} + +
+ + setValue(e.target.value)} + aria-invalid={value.length > 0 && !matches} + /> +
+ + Отмена + + +
+
+ ) +} + +export function KvFrame({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: ReactNode }> +}) { + return ( + + + {title} + {description} + + +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ + ) +} + +/** Список «значение + подпись + копировать» (параметры issuer). */ +export function CopyList({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: string }> +}) { + return ( + + + {title} + {description} + + +
    + {items.map((item) => ( +
  • + {item.value} + {item.label} + +
  • + ))} +
+
+ + ) +} + +export type StateItem = { + title: string + when: string + text: string + tone?: 'wn' | 'er' + cta: string +} + +/** Пустые и аварийные состояния раздела (как «Состояния раздела» канваса). */ +export function StatesFrame({ + title, + description, + items, +}: { + title: string + description: string + items: StateItem[] +}) { + return ( + + + {title} + {description} + + +
    + {items.map((item) => ( +
  • +
    + {item.title} + {item.tone === 'er' ? 'Ошибка' : item.tone === 'wn' ? 'Внимание' : 'Пусто'} +
    +

    Когда: {item.when}

    +

    {item.text}

    + +
  • + ))} +
+
+ + ) +} + +/** Компактный список «ключ — значение» для боковых окон. */ +export function KvList({ items }: { items: Array<[string, string]> }) { + return ( +
+ {items.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} + +export function CodeBlock({ label, children }: { label: string; children: string }) { + return ( +
+ {label} +
{children}
+
+ ) +} diff --git a/apps/web/src/design-preview/design-preview.css b/apps/web/src/design-preview/design-preview.css new file mode 100644 index 0000000..c298762 --- /dev/null +++ b/apps/web/src/design-preview/design-preview.css @@ -0,0 +1,14 @@ +/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */ +html[data-preview-density='compact'] [data-slot='data-grid-table'] :is(th, td) { + padding-block: 0.25rem; +} + +html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-child { + gap: 0.75rem; +} + +html[data-preview-contrast='high'] { + --muted-foreground: color-mix(in oklch, var(--foreground) 82%, var(--background)); + --border: color-mix(in oklch, var(--foreground) 32%, var(--background)); + --input: color-mix(in oklch, var(--foreground) 40%, var(--background)); +} diff --git a/apps/web/src/design-preview/mock.ts b/apps/web/src/design-preview/mock.ts new file mode 100644 index 0000000..893d342 --- /dev/null +++ b/apps/web/src/design-preview/mock.ts @@ -0,0 +1,438 @@ +/** + * Суррогатные данные живого макета /design-preview. + * Названия приложений, разделов и действий берутся из контрактов + * @authportal/shared; значения (люди, IP, даты) — вымышленные. + */ +import { + AppWindowIcon, + FingerprintIcon, + HistoryIcon, + KeyRoundIcon, + LayoutGridIcon, + LogInIcon, + UsersIcon, + type LucideIcon, +} from 'lucide-react' +import { + APPS, + defaultAppSwitcherConfig, + type AdminUser, + type AppId, + type AuditLogEntry, +} from '@authportal/shared' + +export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in' + +export type NavItem = { + id: string + label: string + to: string + icon: LucideIcon + group: 'Портал' | 'Админ' + /** Точка проблемы в углу иконки. */ + problem?: { tone: 'er' | 'wn'; label: string } +} + +export const NAV_ITEMS: NavItem[] = [ + { id: 'apps', label: 'Приложения', to: '/design-preview/apps', icon: LayoutGridIcon, group: 'Портал' }, + { id: 'account', label: 'Безопасность', to: '/design-preview/account', icon: FingerprintIcon, group: 'Портал' }, + { id: 'users', label: 'Пользователи', to: '/design-preview/users', icon: UsersIcon, group: 'Админ' }, + { + id: 'logins', + label: 'Входы', + to: '/design-preview/logins', + icon: LogInIcon, + group: 'Админ', + problem: { tone: 'er', label: 'Подбор пароля: 6 неудачных входов за 10 минут' }, + }, + { id: 'audit', label: 'Изменения', to: '/design-preview/audit', icon: HistoryIcon, group: 'Админ' }, + { + id: 'oidc', + label: 'OIDC-клиенты', + to: '/design-preview/oidc', + icon: KeyRoundIcon, + group: 'Админ', + problem: { tone: 'wn', label: 'Секрет клиента старше 90 дней' }, + }, + { id: 'app-links', label: 'Ссылки приложений', to: '/design-preview/app-links', icon: AppWindowIcon, group: 'Админ' }, +] + +export const NAV_GROUPS = ['Портал', 'Админ'] as const + +export const APP_TITLE: Record = Object.fromEntries( + APPS.map((app) => [app.id, app.title]), +) as Record + +export const APP_SHORT: Record = { + cfdm: 'CFDM', + vps: 'VPS', + bgp: 'EvoBGP', + fw: 'EvoFirewall', + dns: 'Technitium DNS', + cdn: 'CDN Manager', + mm: 'MikrotikManager', +} + +export const ALL_APP_IDS = APPS.map((a) => a.id) + +export const MONITOR_ITEMS: Array<{ + id: string + tone: 'er' | 'wn' | 'ok' + title: string + text: string + age: string + to: string +}> = [ + { + id: 'bruteforce', + tone: 'er', + title: 'Подбор пароля: 6 неудачных входов', + text: 'ops@shnt.top · 203.0.113.58 · за 10 минут', + age: '11 мин', + to: '/design-preview/logins', + }, + { + id: 'secret', + tone: 'wn', + title: 'Секрет Technitium DNS старше 90 дней', + text: 'Обновите client_secret в разделе OIDC', + age: '3 дн', + to: '/design-preview/oidc', + }, + { id: 'api', tone: 'ok', title: 'API портала', text: '/ready · ответ 18 мс', age: 'сейчас', to: '/design-preview/apps' }, + { id: 'jwks', tone: 'ok', title: 'JWKS и discovery', text: 'Подпись RS256 · ключ активен', age: 'сейчас', to: '/design-preview/oidc' }, +] + +const ALL: AppId[] = [...ALL_APP_IDS] + +function user( + n: number, + email: string, + name: string, + isAdmin: boolean, + apps: AppId[], + permissions: string[], + extra: Partial = {}, +): AdminUser { + return { + id: `u-${n}`, + email, + name, + is_admin: isAdmin, + disabled: false, + apps, + permissions, + last_login_at: '2026-10-17T09:41:00.000Z', + last_login_ip: '203.0.113.10', + passkey_count: 0, + created_at: '2026-02-01T08:00:00.000Z', + updated_at: '2026-10-17T09:41:00.000Z', + ...extra, + } +} + +export const MOCK_USERS: AdminUser[] = [ + user(1, 'admin@shnt.top', 'Admin', true, ALL, [], { passkey_count: 2 }), + user(2, 'denis@shnt.top', 'Денис', true, ALL, [], { + passkey_count: 1, + last_login_at: '2026-10-16T22:15:00.000Z', + last_login_ip: '198.51.100.4', + }), + user( + 3, + 'ops@shnt.top', + 'Operations', + false, + ['bgp', 'fw', 'mm'], + ['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', + last_login_at: '2026-10-17T14:02:00.000Z', + last_login_ip: '203.0.113.58', + }, + ), + user( + 4, + 'anna@shnt.top', + 'Анна', + false, + ['cfdm', 'vps'], + ['cfdm:domains:write', 'cfdm:dns:write', 'cfdm:certificates:read', 'vps:dashboard:read', 'vps:vps:read'], + { + passkey_count: 1, + created_at: '2026-05-02T08:00:00.000Z', + last_login_at: '2026-10-16T18:40:00.000Z', + last_login_ip: '192.0.2.7', + }, + ), + user(5, 'pavel@shnt.top', 'Павел', false, ['cdn'], ['cdn:dashboard:read', 'cdn:nodes:read'], { + created_at: '2026-06-19T08:00:00.000Z', + last_login_at: '2026-10-12T11:10:00.000Z', + last_login_ip: '192.0.2.19', + }), + user(6, 'dns-bot@shnt.top', 'Сервисная запись', false, ['dns'], ['dns:dns:write'], { + created_at: '2026-07-01T08:00:00.000Z', + last_login_at: '2026-10-17T06:00:00.000Z', + last_login_ip: '10.0.0.5', + }), + user(7, 'former@shnt.top', 'Бывший сотрудник', false, [], [], { + disabled: true, + created_at: '2026-01-10T08:00:00.000Z', + last_login_at: '2026-10-02T10:00:00.000Z', + last_login_ip: '192.0.2.88', + }), +] + +export const USER_JOURNAL = [ + '14:02 auth.login_failed 203.0.113.58 неверный пароль', + '13:58 auth.login_failed 203.0.113.58 неверный пароль', + '13:51 auth.login 198.51.100.4 пароль', + '09:12 user.access_update admin +fw:agents:write', +].join('\n') + +export type PasskeyRow = { + id: string + name: string + note: string + kind: string + added: string + lastUsed: string + tone: Tone + status: string +} + +export const MOCK_PASSKEYS: PasskeyRow[] = [ + { + id: 'pk-1', + name: 'MacBook Pro · Touch ID', + note: 'Синхронизируется через iCloud', + kind: 'Платформенный', + added: '12.09.2026', + lastUsed: 'сегодня 09:41', + tone: 'ok', + status: 'Активен', + }, + { + id: 'pk-2', + name: 'YubiKey 5C', + note: 'Ключ безопасности · USB-C', + kind: 'Ключ безопасности', + added: '03.07.2026', + lastUsed: '41 день назад', + tone: 'wn', + status: 'Давно не использовался', + }, +] + +export type LoginRow = { + id: string + login: string + client: string + time: string + ip: string + app: string + reason: string + tone: Tone + status: string +} + +export const MOCK_LOGINS: LoginRow[] = [ + { id: 'l1', login: 'ops@shnt.top', client: 'Chrome 130 · Windows 11', time: '14:02', ip: '203.0.113.58', app: 'Auth Portal', reason: 'неверный пароль', tone: 'er', status: 'Неудача' }, + { id: 'l2', login: 'ops@shnt.top', client: 'Chrome 130 · Windows 11', time: '13:58', ip: '203.0.113.58', app: 'Auth Portal', reason: 'неверный пароль', tone: 'er', status: 'Неудача' }, + { id: 'l3', login: 'admin@shnt.top', client: 'Chrome 130 · macOS', time: '09:41', ip: '203.0.113.10', app: 'Auth Portal', reason: 'passkey', tone: 'ok', status: 'Вход' }, + { id: 'l4', login: 'admin@shnt.top', client: 'Chrome 130 · macOS', time: '09:42', ip: '203.0.113.10', app: 'EvoFirewall', reason: 'SSO handoff', tone: 'in', status: 'SSO' }, + { id: 'l5', login: 'anna@shnt.top', client: 'Safari 18 · iPhone', time: 'вчера 18:40', ip: '192.0.2.7', app: 'Auth Portal', reason: 'пароль', tone: 'ok', status: 'Вход' }, + { id: 'l6', login: 'anna@shnt.top', client: 'Safari 18 · iPhone', time: 'вчера 19:12', ip: '192.0.2.7', app: 'Auth Portal', reason: 'выход', tone: 'wn', status: 'Выход' }, + { id: 'l7', login: 'dns-bot@shnt.top', client: 'Technitium 13', time: 'вчера 06:00', ip: '10.0.0.5', app: 'Technitium DNS', reason: 'OIDC', tone: 'in', status: 'SSO' }, + { id: 'l8', login: 'former@shnt.top', client: 'Firefox 131', time: '15 дн назад', ip: '192.0.2.88', app: 'Auth Portal', reason: 'учётная запись отключена', tone: 'er', status: 'Неудача' }, +] + +export type SessionRow = { + id: string + user: string + note: string + ipClient: string + created: string + expires: string + tone: Tone + status: string +} + +export const MOCK_SESSIONS: SessionRow[] = [ + { id: 's1', user: 'admin@shnt.top', note: 'текущая сессия', ipClient: '203.0.113.10 · Chrome · macOS', created: 'сегодня 09:41', expires: 'через 8 ч', tone: 'ok', status: 'Активна' }, + { id: 's2', user: 'admin@shnt.top', note: 'мобильное устройство', ipClient: '198.51.100.4 · Safari · iPhone', created: 'вчера 22:15', expires: 'через 3 ч', tone: 'ok', status: 'Активна' }, + { id: 's3', user: 'denis@shnt.top', note: '', ipClient: '198.51.100.4 · Chrome · Windows', created: 'вчера 22:15', expires: 'через 3 ч', tone: 'ok', status: 'Активна' }, + { id: 's4', user: 'anna@shnt.top', note: '', ipClient: '192.0.2.7 · Safari · iPhone', created: 'вчера 18:40', expires: 'через 1 ч', tone: 'ok', status: 'Активна' }, + { id: 's5', user: 'dns-bot@shnt.top', note: 'OIDC-клиент', ipClient: '10.0.0.5 · Technitium 13', created: 'вчера 06:00', expires: 'через 6 д', tone: 'in', status: 'OIDC' }, +] + +function entry( + n: number, + p: Pick & + Partial, +): AuditLogEntry { + return { + id: `a-${n}`, + event_id: null, + actor_user_id: 'u-1', + actor_email: 'admin@shnt.top', + actor_name: 'Admin', + target_type: null, + target_id: null, + details: null, + ip: '203.0.113.10', + ...p, + } +} + +const NOW = Date.now() +const ago = (minutes: number) => new Date(NOW - minutes * 60_000).toISOString() + +export const MOCK_AUDIT: AuditLogEntry[] = [ + entry(1, { + source_app: 'portal', + action: 'user.update', + severity: 'warning', + summary: 'Отключён пользователь former@shnt.top', + created_at: ago(20), + target_type: 'user', + target_id: 'u-7', + details: { before: { disabled: false }, after: { disabled: true } }, + }), + entry(2, { + source_app: 'portal', + action: 'user.access_update', + severity: 'info', + summary: 'Права обновлены: ops@shnt.top (+fw:agents:write)', + created_at: ago(32), + target_type: 'user', + target_id: 'u-3', + }), + entry(3, { + source_app: 'fw', + action: 'fw.policy_apply', + severity: 'critical', + summary: 'Применена политика firewall (поколение 128) — ошибка применения на db-01', + created_at: ago(111), + actor_user_id: null, + actor_email: null, + actor_name: 'system', + ip: null, + target_type: 'system', + }), + entry(4, { + source_app: 'portal', + action: 'oidc.client_update', + severity: 'info', + summary: 'Обновлён OIDC-клиент Technitium DNS: новый client_secret', + created_at: ago(310), + target_type: 'settings', + }), + entry(5, { + source_app: 'portal', + action: 'app_switcher.update', + severity: 'info', + summary: 'Изменён список приложений в переключателе (+CDN Manager)', + created_at: ago(1200), + target_type: 'settings', + }), + entry(6, { + source_app: 'mm', + action: 'mm.server_delete', + severity: 'warning', + summary: 'Удалён сервер old-edge', + created_at: ago(1400), + actor_email: 'denis@shnt.top', + actor_name: 'Денис', + ip: '198.51.100.4', + target_type: 'app_resource', + }), + entry(7, { + source_app: 'portal', + action: 'auth.login_failed', + severity: 'warning', + summary: 'ops@shnt.top: неверный пароль', + created_at: ago(21), + ip: '203.0.113.58', + actor_email: 'ops@shnt.top', + actor_name: 'Operations', + }), + entry(8, { + source_app: 'portal', + action: 'user.create', + severity: 'info', + summary: 'Создан pavel@shnt.top', + created_at: ago(60 * 24 * 5), + target_type: 'user', + target_id: 'u-5', + }), + entry(9, { + source_app: 'portal', + action: 'audit.purge', + severity: 'warning', + summary: 'Очищено 212 записей старше 90 дней', + created_at: ago(60 * 24 * 16), + target_type: 'system', + }), +] + +export type OidcClientRow = { + id: string + name: string + hint: string + clientId: string + redirect: string + secret: string + created: string + enabled: boolean + stale: boolean +} + +export const MOCK_OIDC_CLIENTS: OidcClientRow[] = [ + { + id: 'c1', + name: 'Technitium DNS', + hint: 'dns.shnt.top · authorization_code', + clientId: 'dns-shnt', + redirect: 'https://dns.shnt.top/sso/callback', + secret: 'старше 90 дн.', + created: '10.07.2026', + enabled: true, + stale: true, + }, + { + id: 'c2', + name: 'Grafana (тест)', + hint: 'grafana.shnt.top · authorization_code', + clientId: 'grafana-test', + redirect: 'https://grafana.shnt.top/login/generic_oauth', + secret: 'действует', + created: '02.10.2026', + enabled: false, + stale: false, + }, +] + +export const OIDC_ISSUER = 'https://auth.shnt.top' + +export const OIDC_ENDPOINTS: Array<{ label: string; value: string }> = [ + { label: 'Issuer', value: OIDC_ISSUER }, + { label: 'Discovery', value: `${OIDC_ISSUER}/.well-known/openid-configuration` }, + { label: 'JWKS', value: `${OIDC_ISSUER}/.well-known/jwks.json` }, + { label: 'Authorization endpoint', value: `${OIDC_ISSUER}/oauth/authorize` }, + { label: 'Token endpoint', value: `${OIDC_ISSUER}/oauth/token` }, + { label: 'Userinfo endpoint', value: `${OIDC_ISSUER}/oauth/userinfo` }, +] + +export const OIDC_CAPABILITIES: Array<{ label: string; value: string }> = [ + { label: 'Flow', value: 'authorization_code + PKCE' }, + { label: 'Scopes', value: 'openid, profile, email, groups' }, + { label: 'Подпись', value: 'RS256' }, + { label: 'Claims', value: 'sub, email, name, groups' }, + { label: 'Время жизни ID-токена', value: '10 минут' }, + { label: 'Refresh-токены', value: 'не выдаются' }, +] + +export const MOCK_CLIENT_SECRET = 'kP9x-3vQ_t7Lw2Zr0mYdN8aUe5HcJf1B' + +export const MOCK_SWITCHER = defaultAppSwitcherConfig() diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx new file mode 100644 index 0000000..154625e --- /dev/null +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from 'lucide-react' +import { toast } from 'sonner' +import { APPS, type AppId } from '@authportal/shared' +import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor' +import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' +import { Button } from '@authportal/ui/components/button' +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 { PageHead, PreviewSheet, StatesFrame, StatusBadge } from '../bits' +import { MOCK_SWITCHER } from '../mock' +import { usePreviewStore } from '../preview-store' +import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' + +type LinkRow = { + id: AppId + name: string + url: string + icon: string + mode: 'JWT' | 'OIDC' + enabled: boolean +} + +const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({ + id: a.id, + name: a.name, + url: a.url, + icon: a.icon, + mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC', + enabled: a.id !== 'mm', +})) + +export function AppLinksPage() { + const { addChange } = usePreviewStore() + const [rows, setRows] = useState(INITIAL) + const rowsRef = useRef(rows) + useEffect(() => { + rowsRef.current = rows + }, [rows]) + const [editing, setEditing] = useState(null) + + const setEnabled = useCallback( + (ids: AppId[], enabled: boolean) => { + const before = rowsRef.current + const names = before.filter((r) => ids.includes(r.id) && r.enabled !== enabled).map((r) => r.name) + if (names.length === 0) return + setRows((prev) => prev.map((r) => (ids.includes(r.id) ? { ...r, enabled } : r))) + addChange({ + kind: 'chg', + title: enabled ? 'Показать в меню' : 'Скрыть из меню', + lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`), + revert: () => setRows(before), + }) + }, + [addChange], + ) + + const move = useCallback( + (id: AppId, dir: -1 | 1) => { + const before = rowsRef.current + const i = before.findIndex((r) => r.id === id) + const j = i + dir + if (i < 0 || j < 0 || j >= before.length) return + const next = [...before] + ;[next[i], next[j]] = [next[j]!, next[i]!] + setRows(next) + addChange({ + kind: 'chg', + title: 'Порядок изменён', + lines: [` ${before[i]!.name}`, `− позиция ${i + 1}`, `+ позиция ${j + 1}`], + revert: () => setRows(before), + }) + }, + [addChange], + ) + + const columns = useMemo[]>( + () => [ + { id: 'name', title: 'Приложение', size: 260, cell: (r) => }, + { id: 'menu', title: 'В меню', size: 150, cell: (r) => {r.enabled ? 'В меню' : 'Скрыто'} }, + { id: 'icon', title: 'Иконка', size: 110, cell: (r) => {r.icon} }, + { id: 'url', title: 'URL', size: 260, cell: (r) => {r.url} }, + { id: 'mode', title: 'Вход', size: 90, cell: (r) => {r.mode} }, + { + id: 'order', + title: 'Порядок', + size: 130, + cell: (r) => ( +
e.stopPropagation()}> + + +
+ ), + }, + ], + [move], + ) + + const shown = rows.filter((r) => r.enabled).length + + return ( + <> + toast('Добавление приложения недоступно в макете', { description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.' })}> + + Добавить приложение + + } + /> + r.mode === 'JWT').length), hint: 'fragment-токен' }, + { id: 'oidc', label: 'OIDC', value: String(rows.filter((r) => r.mode === 'OIDC').length), hint: 'Technitium DNS' }, + ]} + /> + + + Приложения · {rows.length} + Меню + + + r.id} + selectable + searchText={(r) => `${r.name} ${r.id} ${r.url}`} + searchPlaceholder="Название или адрес" + views={[ + { label: 'Все', match: () => true }, + { label: 'В меню', match: (r) => r.enabled }, + { label: 'Скрытые', match: (r) => !r.enabled }, + ]} + bulkActions={(ids, clear) => ( + <> + + + + )} + onRowClick={setEditing} + /> + + + + toast.success('Переключатель сохранён (макет)')} + /> + + + + !o && setEditing(null)} + title="Приложение" + description="Название, иконка и адрес в переключателе" + footer={ + <> + + + + } + > + {editing ? ( + <> + + Название + + + + URL + + https · домен из allowlist return_to + + + Режим входа + + Приложения из каталога: {APPS.length} + + + +
+ Включено в переключатель + Скрытое приложение остаётся доступным по прямой ссылке +
+
+ + ) : null} +
+ + ) +} diff --git a/apps/web/src/design-preview/pages/apps-page.tsx b/apps/web/src/design-preview/pages/apps-page.tsx new file mode 100644 index 0000000..1382a54 --- /dev/null +++ b/apps/web/src/design-preview/pages/apps-page.tsx @@ -0,0 +1,105 @@ +import { Link } from '@tanstack/react-router' +import { + CloudIcon, + GlobeIcon, + LayoutDashboardIcon, + ServerIcon, + ShieldIcon, +} from 'lucide-react' +import { toast } from 'sonner' +import { APPS, type AppId } from '@authportal/shared' +import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' +import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' +import { Button } from '@authportal/ui/components/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' +import { PageHead, StatesFrame } from '../bits' + +const ICONS: Record = { + cfdm: CloudIcon, + vps: ServerIcon, + bgp: GlobeIcon, + fw: ShieldIcon, + dns: GlobeIcon, + cdn: CloudIcon, + mm: LayoutDashboardIcon, +} + +const NO_ACCESS: AppId[] = ['cdn'] + +export function AppsPage() { + const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id)) + const denied = APPS.filter((a) => NO_ACCESS.includes(a.id)) + + return ( + <> + } nativeButton={false}> + Ссылки приложений + + } + /> + a.authMode === 'jwt').length), hint: 'fragment-токен' }, + { id: 'oidc', label: 'OIDC', value: String(allowed.filter((a) => a.authMode === 'oidc').length), hint: 'Technitium DNS' }, + { id: 'role', label: 'Роль', value: 'Админ', hint: 'is_admin' }, + ]} + /> + + + Доступные · {allowed.length} + Без доступа · {denied.length} + + + { + const Icon = ICONS[app.id] + return { + id: app.id, + title: app.title, + description: app.description, + badgeLabel: 'Открыть', + icon: , + onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }), + } + })} + /> + + + + { + const Icon = ICONS[app.id] + return { + id: app.id, + title: app.title, + description: app.description, + badgeLabel: 'Нет доступа', + icon: , + onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }), + } + })} + /> + + + + ) +} diff --git a/apps/web/src/design-preview/pages/audit-page.tsx b/apps/web/src/design-preview/pages/audit-page.tsx new file mode 100644 index 0000000..1ceb3a3 --- /dev/null +++ b/apps/web/src/design-preview/pages/audit-page.tsx @@ -0,0 +1,189 @@ +import { useMemo, useState } from 'react' +import { DownloadIcon, Trash2Icon } from 'lucide-react' +import { toast } from 'sonner' +import type { AuditLogEntry, AuditSeverity } from '@authportal/shared' +import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline' +import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel' +import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' +import { Button } from '@authportal/ui/components/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' +import { CodeBlock, KvList, PageHead, PreviewSheet, StatesFrame, StatusBadge, TypedConfirm } from '../bits' +import { MOCK_AUDIT, APP_SHORT, type Tone } from '../mock' +import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' + +const SEVERITY_TONE: Record = { info: 'ok', warning: 'wn', critical: 'er' } +const SEVERITY_LABEL: Record = { info: 'Info', warning: 'Warning', critical: 'Critical' } + +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 VIEWS = [ + { label: 'Все', match: () => true }, + { label: 'Warning и выше', match: (r: AuditLogEntry) => r.severity !== 'info' }, + { label: 'Critical', match: (r: AuditLogEntry) => r.severity === 'critical' }, +] + +export function AuditPage() { + const [entry, setEntry] = useState(null) + const [purgeOpen, setPurgeOpen] = useState(false) + const [retention, setRetention] = useState(90) + const [period, setPeriod] = useState(1) + + const auth = useMemo(() => MOCK_AUDIT.filter((e) => e.action.startsWith('auth.')), []) + const users = useMemo(() => MOCK_AUDIT.filter((e) => e.action.startsWith('user.')), []) + const settings = useMemo( + () => MOCK_AUDIT.filter((e) => e.action.startsWith('oidc.') || e.action.startsWith('app_switcher.') || e.action.startsWith('audit.')), + [], + ) + + function grid(title: string, description: string, rows: AuditLogEntry[]) { + return ( + r.id} + searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`} + searchPlaceholder="Событие, пользователь, IP" + views={VIEWS} + onRowClick={setEntry} + /> + ) + } + + return ( + <> + + + + + } + /> +
+ {['24 ч', '7 д', '30 д', '90 д', 'Весь период'].map((p, i) => ( + + ))} +
+ e.severity === 'critical').length), hint: 'critical', variant: 'destructive' }, + { id: 'src', label: 'Источников', value: '6', hint: 'портал + 5 приложений' }, + ]} + /> + + + Все · 148 + Auth · 31 + Пользователи · 74 + Настройки · 43 + Ротация + + + + {grid('Журнал изменений', 'Поиск по событию, пользователю и IP · фильтры: источник, важность, объект', MOCK_AUDIT)} + + + {grid('События аутентификации', 'Входы, выходы и SSO · подробности — в разделе «Входы»', auth)} + {grid('Изменения пользователей', 'Создание, правки, права и passkeys', users)} + {grid('Изменения настроек', 'Переключатель приложений, OIDC, ротация журнала', settings)} + + { + setRetention(days) + toast.success(`Срок хранения: ${days} дн. (макет)`) + }} + onPurge={() => toast.success('Старые записи очищены (макет)')} + /> + + + + !o && setEntry(null)} + title="Событие журнала" + description="Детали записи" + footer={} + > + {entry ? ( + <> +
+ {SEVERITY_LABEL[entry.severity]} +
+ + + {JSON.stringify({ target_type: entry.target_type, target_id: entry.target_id, ...entry.details }, null, 2)} + + + ) : null} +
+ + { + setPurgeOpen(false) + toast.success('Очищено 212 записей (макет)') + }} + /> + + ) +} diff --git a/apps/web/src/design-preview/pages/login-page.tsx b/apps/web/src/design-preview/pages/login-page.tsx new file mode 100644 index 0000000..8418933 --- /dev/null +++ b/apps/web/src/design-preview/pages/login-page.tsx @@ -0,0 +1,150 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldCheckIcon } from 'lucide-react' +import { Badge } from '@/components/reui/badge' +import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame' +import { IconTile } from '@/components/reui/icon-tile' +import { AuthLogo } from '@/components/auth-logo' +import { Button } from '@authportal/ui/components/button' +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@authportal/ui/components/field' +import { Input } from '@authportal/ui/components/input' +import { Separator } from '@authportal/ui/components/separator' +import { SignalAlert } from '../bits' + +const PERKS = [ + { icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' }, + { icon: ShieldCheckIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' }, + { icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' }, +] + +/** Страница входа вне оболочки. Данные не отправляются: «Войти» имитирует запрос. */ +export function LoginPage() { + const navigate = useNavigate() + const [shown, setShown] = useState(false) + const [error, setError] = useState(false) + const [pending, setPending] = useState(false) + + function submit(e: FormEvent) { + e.preventDefault() + setPending(true) + window.setTimeout(() => { + setPending(false) + if (error) { + void navigate({ to: '/design-preview/apps' }) + } else { + setError(true) + } + }, 600) + } + + return ( +
+
+ + + + + Вход в портал + Введите email и пароль или используйте passkey + + +
+
+ SSO + + После входа вы вернётесь в EvoFirewall · fw.shnt.top + +
+ {error ? ( + + Неверный email или пароль. Осталось 4 попытки. + + ) : null} + + + Email + + + + Пароль +
+ + +
+ {error ? Проверьте пароль и повторите попытку : null} +
+
+ +
+ + или + +
+ + + Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность». + +
+
+ +
+
+ ) +} diff --git a/apps/web/src/design-preview/pages/logins-page.tsx b/apps/web/src/design-preview/pages/logins-page.tsx new file mode 100644 index 0000000..1a5b3d0 --- /dev/null +++ b/apps/web/src/design-preview/pages/logins-page.tsx @@ -0,0 +1,191 @@ +import { useMemo, useState } from 'react' +import { Link, useNavigate } from '@tanstack/react-router' +import { DownloadIcon } 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 { CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatesFrame, StatusBadge } from '../bits' +import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock' +import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' + +const PERIODS = ['24 ч', '7 д', '30 д', '90 д'] + +export function LoginsPage() { + const navigate = useNavigate() + const [period, setPeriod] = useState(0) + const [event, setEvent] = useState(null) + const [sessions, setSessions] = useState(MOCK_SESSIONS) + + 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} }, + ], + [], + ) + + const sessionCols = useMemo[]>( + () => [ + { 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) => ( + + ), + }, + ], + [], + ) + + return ( + <> + toast.success('CSV сформирован (макет)')}> + + Экспорт CSV + + } + /> + } nativeButton={false}> + К пользователям + + } + > + 6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи». + +
+ {PERIODS.map((p, i) => ( + + ))} +
+ + + + История · 44 + Активные сессии · {sessions.length} + + + r.id} + searchText={(r) => `${r.login} ${r.ip} ${r.app}`} + searchPlaceholder="Логин или IP" + views={[ + { label: 'Все', match: () => true }, + { label: 'Успешные', match: (r) => r.tone === 'ok' || r.tone === 'in' }, + { label: 'Неудачные', match: (r) => r.tone === 'er' || r.tone === 'wn' }, + ]} + onRowClick={setEvent} + /> + + + + r.id} + emptyMessage="Нет активных сессий." + /> + + + + !o && setEvent(null)} + title="Событие входа" + description="Детали записи журнала" + footer={ + <> + + + + } + > + {event ? ( + <> +
+ {event.status} +
+ + {`action: ${event.tone === 'er' ? 'auth.login_failed' : 'auth.login'}\nsource_app: portal\nseverity: ${event.tone === 'er' ? 'warning' : 'info'}\nip: ${event.ip}`} + + ) : null} +
+ + ) +} diff --git a/apps/web/src/design-preview/pages/oidc-page.tsx b/apps/web/src/design-preview/pages/oidc-page.tsx new file mode 100644 index 0000000..9d90604 --- /dev/null +++ b/apps/web/src/design-preview/pages/oidc-page.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from 'react' +import { CopyIcon, PlusIcon, RefreshCwIcon } from 'lucide-react' +import { toast } from 'sonner' +import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' +import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' +import { Button } from '@authportal/ui/components/button' +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@authportal/ui/components/alert-dialog' +import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field' +import { Input } from '@authportal/ui/components/input' +import { Switch } from '@authportal/ui/components/switch' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' +import { Textarea } from '@authportal/ui/components/textarea' +import { CopyList, KvFrame, PageHead, PreviewSheet, SignalAlert, StatesFrame, StatusBadge, TypedConfirm } from '../bits' +import { + MOCK_CLIENT_SECRET, + MOCK_OIDC_CLIENTS, + OIDC_CAPABILITIES, + OIDC_ENDPOINTS, + type OidcClientRow, + type Tone, +} from '../mock' +import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' + +function tone(c: OidcClientRow): Tone { + return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok' +} +function statusLabel(c: OidcClientRow) { + return !c.enabled ? 'Выключен' : c.stale ? 'Секрет устарел' : 'Включён' +} + +export function OidcPage() { + const [clients, setClients] = useState(MOCK_OIDC_CLIENTS) + const [createOpen, setCreateOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [rotating, setRotating] = useState(null) + const [secret, setSecret] = useState(null) + + const columns = useMemo[]>( + () => [ + { 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) => ( + + ), + }, + ], + [], + ) + + function onCreate(values: CreateOidcClientValues) { + const created: OidcClientRow = { + id: `c-${clients.length + 1}`, + name: values.name, + hint: `${values.redirect_uris[0]?.replace(/^https:\/\//, '').split('/')[0] ?? ''} · authorization_code`, + clientId: values.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'client', + redirect: values.redirect_uris[0] ?? '', + secret: 'действует', + created: '17.10.2026', + enabled: values.enabled, + stale: false, + } + setClients((c) => [...c, created]) + setCreateOpen(false) + setSecret(MOCK_CLIENT_SECRET) + } + + const staleCount = clients.filter((c) => c.stale && c.enabled).length + + return ( + <> + setCreateOpen(true)}> + + Создать клиента + + } + /> + {staleCount > 0 ? ( + + Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления. + + ) : null} + c.enabled).length), hint: 'принимают вход' }, + { id: 'tok', label: 'Токенов за 24 ч', value: '41', hint: 'authorization_code' }, + { id: 'key', label: 'Ключ подписи', value: 'RS256', hint: 'ротация вручную' }, + ]} + /> + + + Клиенты · {clients.length} + Параметры issuer + + + r.id} + searchText={(r) => `${r.name} ${r.clientId} ${r.redirect}`} + searchPlaceholder="Название или client_id" + views={[ + { label: 'Все', match: () => true }, + { label: 'Включённые', match: (r) => r.enabled }, + { label: 'Выключенные', match: (r) => !r.enabled }, + ]} + onRowClick={setEditing} + /> + + + + + + + + + + + !o && setEditing(null)} + title="OIDC-клиент" + description="Параметры клиента" + footer={ + <> + + + + } + > + {editing ? ( + <> + + Название + + + + client_id + + Не меняется + + + Redirect URI +