feat(design-preview): живой макет портала на ReUI PRO

Добавлена ветка маршрутов /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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-17 22:05:52 +07:00
1 parent 5acff590bc
commit 157396613d
33 files changed
+4082 -6

No files matched your search

@@ -116,7 +116,7 @@ function EventRow({
const reason = detailString(event.details, 'reason')
return (
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
<TimelineItem step={step} className={cn('ms-10 min-w-0', isLast ? 'pb-0' : 'pb-6')}>
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<div className="flex flex-wrap items-center gap-2">
@@ -145,8 +145,8 @@ function EventRow({
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2">
<Frame stacked dense spacing="sm">
<TimelineContent className="mt-2 min-w-0">
<Frame stacked dense spacing="sm" className="min-w-0">
<Collapsible
open={open}
onOpenChange={setOpen}
@@ -154,10 +154,10 @@ function EventRow({
>
<CollapsibleTrigger
type="button"
className="flex w-full"
className="flex w-full min-w-0"
aria-label={`Подробности: ${meta.label}`}
>
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
<FrameHeader className="flex min-w-0 grow flex-row items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Avatar className="size-5">
<AvatarFallback className="text-[10px]">
@@ -327,7 +327,7 @@ export function AuditLogTimeline({
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={source}
onValueChange={(value) => {
@@ -0,0 +1,197 @@
import { useMemo, useState } from 'react'
import {
PERMISSION_CATALOG,
hasPermission,
permissionKey,
type AdminUser,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APPS } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { CodeBlock, SignalAlert } from './bits'
import { diffAccess, type AccessValue } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
type Level = 'none' | PermissionAction
const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет доступа',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
function levelOf(perms: readonly string[], appId: AppId, sectionId: string, actions: PermissionAction[]): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) {
return action
}
}
return 'none'
}
/** Ключи для уровня: иерархия админ ⊃ запись ⊃ чтение внутри раздела. */
function keysForLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
const upto = order.indexOf(level)
return order
.slice(0, upto + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Окно «Права доступа»: вкладки по приложениям и разделам, локальное состояние, без API. */
export function AccessDialog({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => void
}) {
const [apps, setApps] = useState<AppId[]>(user.apps)
const [permissions, setPermissions] = useState<string[]>(user.permissions)
const next = useMemo<AccessValue>(() => ({ apps, permissions }), [apps, permissions])
const lines = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, next), [user, next])
function setLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level) {
setPermissions((prev) => {
const prefix = `${appId}:${sectionId}:`
const rest = prev.filter((k) => !k.startsWith(prefix))
return [...rest, ...keysForLevel(appId, sectionId, actions, level)].sort()
})
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[calc(100svh-2rem)] w-[min(52rem,calc(100vw-2rem))] max-w-none flex-col gap-4 sm:max-w-none">
<DialogHeader>
<DialogTitle>Права доступа · {user.email}</DialogTitle>
<DialogDescription>Настройка доступа к приложениям и разделам</DialogDescription>
</DialogHeader>
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="apps">Приложения</TabsTrigger>
{PERMISSION_CATALOG.map((c) => (
<TabsTrigger key={c.appId} value={c.appId}>
{APP_SHORT[c.appId]}
</TabsTrigger>
))}
<TabsTrigger value="journal">Журнал</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((app) => (
<li key={app.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<label htmlFor={`app-${app.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{app.title}</span>
<span className="text-muted-foreground text-xs">{app.description}</span>
</label>
<Switch
id={`app-${app.id}`}
checked={apps.includes(app.id)}
onCheckedChange={(on) =>
setApps((prev) => (on ? [...prev, app.id] : prev.filter((x) => x !== app.id)))
}
/>
</li>
))}
</ul>
</TabsContent>
{PERMISSION_CATALOG.map((catalog) => (
<TabsContent key={catalog.appId} value={catalog.appId} className="min-h-0 overflow-y-auto">
{!apps.includes(catalog.appId) ? (
<p className="text-muted-foreground mb-2 text-xs">
Приложение отключено на вкладке «Приложения» — права разделов не действуют.
</p>
) : null}
<ul className="flex flex-col divide-y rounded-lg border">
{catalog.sections.map((section) => {
const level = levelOf(permissions, catalog.appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5">
<div className="flex min-w-0 flex-1 basis-48 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span>
</div>
<Select
value={level}
onValueChange={(v) => v && setLevel(catalog.appId, section.id, section.actions, v as Level)}
items={options.map((o) => ({ value: o, label: LEVEL_LABEL[o] }))}
>
<SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_LABEL[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</li>
)
})}
</ul>
</TabsContent>
))}
<TabsContent value="journal" className="min-h-0 overflow-y-auto">
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
<DialogFooter className="items-center">
<span className="text-muted-foreground mr-auto text-xs">
{lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
variant="outline"
disabled={lines.length === 0}
onClick={() => {
onSave(next)
onClose()
}}
>
Сохранить права
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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
}
+369
View File
@@ -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 (
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
{children}
</Badge>
)
}
/** Заголовок страницы: название, описание, метка свежести и действия. */
export function PageHead({
title,
sub,
source,
actions,
}: {
title: string
sub: string
source: string
actions?: ReactNode
}) {
return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p>
<p
role="status"
className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs"
>
<span className="bg-success size-1.5 rounded-full" aria-hidden />
<span>Получено 14:02:11 · {source}</span>
</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
const ALERT_ICON = {
er: <CircleAlertIcon aria-hidden />,
wn: <TriangleAlertIcon aria-hidden />,
in: <InfoIcon aria-hidden />,
} 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 variant={ALERT_VARIANT[tone]}>
{ALERT_ICON[tone]}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
{action ? <AlertAction>{action}</AlertAction> : null}
</Alert>
)
}
/** Боковое окно (как 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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(26rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="text-muted-foreground hover:text-foreground"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
</div>
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
{description}
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">{children}</div>
{footer ? (
<SheetFooter className="flex-row justify-end gap-2 border-t px-4 py-3">{footer}</SheetFooter>
) : null}
</SheetContent>
</Sheet>
)
}
/** Подтверждение опасного действия вводом слова (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 (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setValue('')
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="typed-confirm">
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
</Label>
<Input
id="typed-confirm"
value={value}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
aria-invalid={value.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={!matches}
onClick={() => {
setValue('')
onConfirm()
}}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function KvFrame({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
{items.map((item) => (
<div
key={item.label}
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
>
<dt className="text-muted-foreground text-xs">{item.label}</dt>
<dd className="text-sm font-medium break-words">{item.value}</dd>
</div>
))}
</dl>
</FramePanel>
</Frame>
)
}
/** Список «значение + подпись + копировать» (параметры issuer). */
export function CopyList({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: string }>
}) {
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ul className="flex flex-col">
{items.map((item) => (
<li
key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
>
<code className="min-w-0 flex-1 basis-64 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground basis-40 text-xs">{item.label}</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
<CopyIcon aria-hidden />
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
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 (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="@container p-0">
<ul className="grid @2xl:grid-cols-2">
{items.map((item) => (
<li key={item.title} className="flex flex-col items-start gap-2 border-b px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{item.title}</span>
<StatusBadge tone={item.tone ?? 'mu'}>{item.tone === 'er' ? 'Ошибка' : item.tone === 'wn' ? 'Внимание' : 'Пусто'}</StatusBadge>
</div>
<p className="text-muted-foreground text-xs">Когда: {item.when}</p>
<p className="text-sm text-pretty">{item.text}</p>
<Button type="button" size="sm" variant="outline" onClick={() => toast(item.cta)}>
{item.cta}
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
/** Компактный список «ключ — значение» для боковых окон. */
export function KvList({ items }: { items: Array<[string, string]> }) {
return (
<dl className="flex flex-col divide-y rounded-lg border">
{items.map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
<dt className="text-muted-foreground text-xs">{k}</dt>
<dd className="text-sm break-words">{v}</dd>
</div>
))}
</dl>
)
}
export function CodeBlock({ label, children }: { label: string; children: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
@@ -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));
}
+438
View File
@@ -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<AppId, string> = Object.fromEntries(
APPS.map((app) => [app.id, app.title]),
) as Record<AppId, string>
export const APP_SHORT: Record<AppId, string> = {
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> = {},
): 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<AuditLogEntry, 'source_app' | 'action' | 'severity' | 'summary' | 'created_at'> &
Partial<AuditLogEntry>,
): 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()
@@ -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<LinkRow[]>(INITIAL)
const rowsRef = useRef(rows)
useEffect(() => {
rowsRef.current = rows
}, [rows])
const [editing, setEditing] = useState<LinkRow | null>(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<GridColumn<LinkRow>[]>(
() => [
{ id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell title={r.name} sub={r.id} /> },
{ id: 'menu', title: 'В меню', size: 150, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
{ id: 'icon', title: 'Иконка', size: 110, cell: (r) => <span className="text-sm">{r.icon}</span> },
{ id: 'url', title: 'URL', size: 260, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.url}</code> },
{ id: 'mode', title: 'Вход', size: 90, cell: (r) => <StatusBadge tone={r.mode === 'OIDC' ? 'wn' : 'in'}>{r.mode}</StatusBadge> },
{
id: 'order',
title: 'Порядок',
size: 130,
cell: (r) => (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button type="button" size="icon-sm" variant="ghost" aria-label={`Выше: ${r.name}`} onClick={() => move(r.id, -1)}>
<ArrowUpIcon aria-hidden />
</Button>
<Button type="button" size="icon-sm" variant="ghost" aria-label={`Ниже: ${r.name}`} onClick={() => move(r.id, 1)}>
<ArrowDownIcon aria-hidden />
</Button>
</div>
),
},
],
[move],
)
const shown = rows.filter((r) => r.enabled).length
return (
<>
<PageHead
title="Ссылки приложений"
sub="Каталог приложений и переключатель в шапке · общий для всех сервисов семейства"
source="portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher"
actions={
<Button variant="outline" onClick={() => toast('Добавление приложения недоступно в макете', { description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.' })}>
<PlusIcon aria-hidden />
Добавить приложение
</Button>
}
/>
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'n', label: 'Приложений', value: String(rows.length), hint: 'в каталоге' },
{ id: 'sw', label: 'В переключателе', value: String(shown), hint: 'видны всем' },
{ id: 'jwt', label: 'JWT SSO', value: String(rows.filter((r) => r.mode === 'JWT').length), hint: 'fragment-токен' },
{ id: 'oidc', label: 'OIDC', value: String(rows.filter((r) => r.mode === 'OIDC').length), hint: 'Technitium DNS' },
]}
/>
<Tabs defaultValue="list" className="min-w-0">
<TabsList>
<TabsTrigger value="list">Приложения · {rows.length}</TabsTrigger>
<TabsTrigger value="menu">Меню</TabsTrigger>
</TabsList>
<TabsContent value="list" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Ссылки сервисов"
description="Порядок строк = порядок в меню · изменения применяются после сохранения"
rows={rows}
columns={columns}
getRowId={(r) => 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) => (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEnabled(ids as AppId[], true)
clear()
}}
>
Показать в меню
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEnabled(ids as AppId[], false)
clear()
}}
>
Скрыть
</Button>
</>
)}
onRowClick={setEditing}
/>
<StatesFrame
title="Состояния раздела"
description="Ошибки адреса, пустое меню и сбой сохранения"
items={[
{ title: 'Некорректный URL', when: 'адрес не https или не входит в allowlist return_to', text: 'Укажите https-адрес из доверенного домена (.shnt.top).', tone: 'wn', cta: 'Исправить' },
{ title: 'Меню пусто', when: 'все приложения скрыты', text: 'В переключателе не останется ни одного приложения — оставьте хотя бы одно.', tone: 'wn', cta: 'Показать приложения' },
{ title: 'Не удалось сохранить', when: 'PUT /api/v1/admin/app-switcher вернул ошибку', text: 'Изменения не записаны. Повторите сохранение.', tone: 'er', cta: 'Повторить' },
]}
/>
</TabsContent>
<TabsContent value="menu">
<AppSwitcherAdminEditor
defaultValues={MOCK_SWITCHER}
onSave={() => toast.success('Переключатель сохранён (макет)')}
/>
</TabsContent>
</Tabs>
<PreviewSheet
open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)}
title="Приложение"
description="Название, иконка и адрес в переключателе"
footer={
<>
<Button variant="outline" onClick={() => setEditing(null)}>Отмена</Button>
<Button
variant="outline"
onClick={() => {
setEditing(null)
toast.success('Приложение сохранено (макет)')
}}
>
Сохранить
</Button>
</>
}
>
{editing ? (
<>
<Field>
<FieldLabel htmlFor="al-name">Название</FieldLabel>
<Input id="al-name" defaultValue={editing.name} />
</Field>
<Field>
<FieldLabel htmlFor="al-url">URL</FieldLabel>
<Input id="al-url" defaultValue={editing.url} />
<FieldDescription>https · домен из allowlist return_to</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="al-mode">Режим входа</FieldLabel>
<Input id="al-mode" value={editing.mode === 'JWT' ? 'JWT SSO (fragment)' : 'OIDC'} readOnly />
<FieldDescription>Приложения из каталога: {APPS.length}</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="al-on" defaultChecked={editing.enabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="al-on">Включено в переключатель</FieldLabel>
<FieldDescription>Скрытое приложение остаётся доступным по прямой ссылке</FieldDescription>
</div>
</Field>
</>
) : null}
</PreviewSheet>
</>
)
}
@@ -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<AppId, typeof CloudIcon> = {
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 (
<>
<PageHead
title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
source="сессия действует до 22:02 · права из базы портала, не из токена"
actions={
<Button variant="outline" size="sm" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
Ссылки приложений
</Button>
}
/>
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'avail', label: 'Доступно приложений', value: String(allowed.length), hint: `из ${APPS.length}` },
{ id: 'jwt', label: 'JWT SSO', value: String(allowed.filter((a) => 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' },
]}
/>
<Tabs defaultValue="all" className="min-w-0">
<TabsList>
<TabsTrigger value="all">Доступные · {allowed.length}</TabsTrigger>
<TabsTrigger value="no">Без доступа · {denied.length}</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex flex-col gap-4">
<QuickActionGrid
title="Мои приложения"
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами"
actions={allowed.map((app) => {
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
icon: <Icon />,
onSelect: () => toast(`Открыть ${app.title}`, { description: `${app.url.replace('https://', '')} · ${app.authMode.toUpperCase()} (макет)` }),
}
})}
/>
<StatesFrame
title="Состояния раздела"
description="Пустой список, сбой SSO, недоступный каталог"
items={[
{ title: 'Нет доступных приложений', when: 'администратор ещё не выдал права', text: 'Обратитесь к администратору, чтобы получить доступ.', cta: 'Написать администратору' },
{ title: 'SSO не удался', when: 'не удалось выпустить токен для приложения', text: 'Открываем приложение по базовому адресу — войдите в нём отдельно. Портал записал событие в журнал.', tone: 'wn', cta: 'Повторить SSO' },
{ title: 'Каталог недоступен', when: 'GET /api/v1/catalog вернул ошибку', text: 'Не удалось загрузить список приложений. Повторите запрос.', tone: 'er', cta: 'Повторить' },
]}
/>
</TabsContent>
<TabsContent value="no">
<QuickActionGrid
title="Недоступные приложения"
description="Права не выданы · запросите у администратора"
actions={denied.map((app) => {
const Icon = ICONS[app.id]
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Нет доступа',
icon: <Icon />,
onSelect: () => toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' }),
}
})}
/>
</TabsContent>
</Tabs>
</>
)
}
@@ -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<AuditSeverity, Tone> = { info: 'ok', warning: 'wn', critical: 'er' }
const SEVERITY_LABEL: Record<AuditSeverity, string> = { 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<AuditLogEntry>[] = [
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
{ id: 'sev', title: 'Важность', size: 120, sortValue: (r) => r.severity, cell: (r) => <StatusBadge tone={SEVERITY_TONE[r.severity]}>{SEVERITY_LABEL[r.severity]}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source_app, cell: (r) => <span className="text-sm">{sourceLabel(r)}</span> },
{ id: 'who', title: 'Кто', size: 140, cell: (r) => <span className="text-sm">{r.actor_name ?? '—'}</span> },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => <span className="font-mono text-[13px]">{r.ip ?? '—'}</span> },
{ id: 'when', title: 'Когда', size: 140, sortValue: (r) => r.created_at, cell: (r) => <span className="text-sm whitespace-nowrap">{whenLabel(r.created_at)}</span> },
]
const VIEWS = [
{ 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<AuditLogEntry | null>(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 (
<PreviewSectionGrid
title={title}
description={description}
rows={rows}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.actor_email ?? ''} ${r.ip ?? ''}`}
searchPlaceholder="Событие, пользователь, IP"
views={VIEWS}
onRowClick={setEntry}
/>
)
}
return (
<>
<PageHead
title="Изменения"
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
source="GET /api/v1/admin/audit?kind=changes · ingest от 5 приложений"
actions={
<>
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button>
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи…
</Button>
</>
}
/>
<div role="group" aria-label="Период" className="bg-muted/60 flex w-fit max-w-full flex-wrap gap-0.5 rounded-lg p-0.5">
{['24 ч', '7 д', '30 д', '90 д', 'Весь период'].map((p, i) => (
<Button
key={p}
type="button"
size="xs"
variant="ghost"
aria-pressed={i === period}
onClick={() => setPeriod(i)}
className={i === period ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}
>
{p}
</Button>
))}
</div>
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'all', label: 'Событий', value: '148', hint: 'за 7 дней' },
{ id: 'acc', label: 'Изменений прав', value: '22', hint: 'user.access_update' },
{ id: 'crit', label: 'Критичных', value: String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), hint: 'critical', variant: 'destructive' },
{ id: 'src', label: 'Источников', value: '6', hint: 'портал + 5 приложений' },
]}
/>
<Tabs defaultValue="all" className="min-w-0">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="all">Все · 148</TabsTrigger>
<TabsTrigger value="auth">Auth · 31</TabsTrigger>
<TabsTrigger value="users">Пользователи · 74</TabsTrigger>
<TabsTrigger value="settings">Настройки · 43</TabsTrigger>
<TabsTrigger value="retention">Ротация</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex min-w-0 flex-col gap-4">
<AuditLogTimeline entries={MOCK_AUDIT} totalCount={MOCK_AUDIT.length} />
{grid('Журнал изменений', 'Поиск по событию, пользователю и IP · фильтры: источник, важность, объект', MOCK_AUDIT)}
<StatesFrame
title="Состояния журнала"
description="Пустые и аварийные состояния"
items={[
{ title: 'Нет событий', when: 'за выбранный период записей нет', text: 'За выбранный период в журнале нет записей.', cta: 'Сменить период' },
{ title: 'Журнал недоступен', when: 'запрос журнала завершился ошибкой', text: 'Не удалось загрузить журнал. Повторите запрос.', tone: 'er', cta: 'Повторить' },
]}
/>
</TabsContent>
<TabsContent value="auth">{grid('События аутентификации', 'Входы, выходы и SSO · подробности — в разделе «Входы»', auth)}</TabsContent>
<TabsContent value="users">{grid('Изменения пользователей', 'Создание, правки, права и passkeys', users)}</TabsContent>
<TabsContent value="settings">{grid('Изменения настроек', 'Переключатель приложений, OIDC, ротация журнала', settings)}</TabsContent>
<TabsContent value="retention">
<AuditRetentionPanel
retentionDays={retention}
saving={false}
purging={false}
onSave={(days) => {
setRetention(days)
toast.success(`Срок хранения: ${days} дн. (макет)`)
}}
onPurge={() => toast.success('Старые записи очищены (макет)')}
/>
</TabsContent>
</Tabs>
<PreviewSheet
open={entry !== null}
onOpenChange={(o) => !o && setEntry(null)}
title="Событие журнала"
description="Детали записи"
footer={<Button variant="outline" onClick={() => setEntry(null)}>Закрыть</Button>}
>
{entry ? (
<>
<div>
<StatusBadge tone={SEVERITY_TONE[entry.severity]}>{SEVERITY_LABEL[entry.severity]}</StatusBadge>
</div>
<KvList
items={[
['Событие', `${entry.action} · ${entry.summary}`],
['Источник', sourceLabel(entry)],
['Кто', `${entry.actor_email ?? 'system'}${entry.ip ? ` · ${entry.ip}` : ''}`],
]}
/>
<CodeBlock label="Подробности">
{JSON.stringify({ target_type: entry.target_type, target_id: entry.target_id, ...entry.details }, null, 2)}
</CodeBlock>
</>
) : null}
</PreviewSheet>
<TypedConfirm
open={purgeOpen}
onOpenChange={setPurgeOpen}
title="Очистить старые записи?"
description={`Будут безвозвратно удалены 212 записей старше ${retention} дней. Само действие попадёт в журнал как audit.purge.`}
expected="очистить"
confirmLabel="Очистить"
onConfirm={() => {
setPurgeOpen(false)
toast.success('Очищено 212 записей (макет)')
}}
/>
</>
)
}
@@ -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 (
<main className="bg-background flex min-h-svh items-center justify-center px-4 py-8 md:px-6">
<div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-2">
<aside className="hidden flex-col gap-6 lg:flex">
<div className="flex items-center gap-3">
<AuthLogo className="size-10" />
<div className="flex flex-col">
<b className="text-base font-semibold">Auth Portal</b>
<span className="text-muted-foreground text-sm">auth.shnt.top</span>
</div>
</div>
<h1 className="text-3xl font-semibold tracking-tight text-balance">
Один вход для всех сервисов shnt.top
</h1>
<p className="text-muted-foreground text-pretty">
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
Technitium DNS без повторного ввода пароля.
</p>
<Frame dense spacing="sm" className="max-w-md">
<FramePanel className="flex flex-col gap-3 py-3">
{PERKS.map(({ icon: Icon, title, text }) => (
<div key={title} className="flex items-start gap-3">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
<Icon />
</IconTile>
<div className="flex flex-col">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-xs">{text}</span>
</div>
</div>
))}
</FramePanel>
</Frame>
</aside>
<Frame className="mx-auto w-full max-w-md">
<FrameHeader>
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
<FrameDescription>Введите email и пароль или используйте passkey</FrameDescription>
</FrameHeader>
<FramePanel>
<form className="flex flex-col gap-4" onSubmit={submit} noValidate>
<div className="flex items-start gap-2 text-sm" role="status">
<Badge variant="info-light" size="sm">SSO</Badge>
<span>
После входа вы вернётесь в <b className="font-semibold">EvoFirewall</b> · fw.shnt.top
</span>
</div>
{error ? (
<SignalAlert tone="er" title="Ошибка входа">
Неверный email или пароль. Осталось 4 попытки.
</SignalAlert>
) : null}
<FieldGroup>
<Field data-invalid={error}>
<FieldLabel htmlFor="dp-email">Email</FieldLabel>
<Input id="dp-email" type="email" defaultValue="admin@shnt.top" autoComplete="username" aria-invalid={error} />
</Field>
<Field data-invalid={error}>
<FieldLabel htmlFor="dp-password">Пароль</FieldLabel>
<div className="relative">
<Input
id="dp-password"
type={shown ? 'text' : 'password'}
defaultValue="correct-horse"
autoComplete="current-password"
aria-invalid={error}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-pressed={shown}
aria-label={shown ? 'Скрыть пароль' : 'Показать пароль'}
className="absolute top-1/2 right-1 -translate-y-1/2"
onClick={() => setShown((v) => !v)}
>
{shown ? <EyeOffIcon aria-hidden /> : <EyeIcon aria-hidden />}
</Button>
</div>
{error ? <FieldError>Проверьте пароль и повторите попытку</FieldError> : null}
</Field>
</FieldGroup>
<Button type="submit" disabled={pending} className="w-full">
{pending ? 'Вход…' : 'Войти'}
</Button>
<div className="text-muted-foreground flex items-center gap-3 text-xs">
<Separator className="flex-1" />
или
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void navigate({ to: '/design-preview/apps' })}
>
<FingerprintIcon aria-hidden />
Войти с passkey
</Button>
<FieldDescription className="text-center">
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
</FieldDescription>
</form>
</FramePanel>
</Frame>
</div>
</main>
)
}
@@ -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<LoginRow | null>(null)
const [sessions, setSessions] = useState<SessionRow[]>(MOCK_SESSIONS)
const loginCols = useMemo<GridColumn<LoginRow>[]>(
() => [
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell title={r.login} sub={r.client} /> },
{ id: 'status', title: 'Результат', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'time', title: 'Время', size: 130, cell: (r) => <span className="text-sm whitespace-nowrap">{r.time}</span> },
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[13px]">{r.ip}</span> },
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="text-sm">{r.app}</span> },
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="text-sm">{r.reason}</span> },
],
[],
)
const sessionCols = useMemo<GridColumn<SessionRow>[]>(
() => [
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell title={r.user} sub={r.note || undefined} /> },
{ id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="text-sm">{r.ipClient}</span> },
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="text-sm whitespace-nowrap">{r.expires}</span> },
{
id: 'act',
title: '',
size: 130,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}}
>
Завершить
</Button>
),
},
],
[],
)
return (
<>
<PageHead
title="Входы"
sub="Журнал входов и активные сессии · портал и SSO в приложения"
source="GET /api/v1/admin/audit?kind=logins · хранение 90 дней"
actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button>
}
/>
<SignalAlert
tone="er"
title="Подозрительная серия неудач: ops@shnt.top."
action={
<Button variant="outline" size="sm" render={<Link to="/design-preview/users" />} nativeButton={false}>
К пользователям
</Button>
}
>
6 неудачных входов с 203.0.113.58 за 10 минут. При необходимости отключите учётную запись в разделе «Пользователи».
</SignalAlert>
<div role="group" aria-label="Период" className="bg-muted/60 flex w-fit max-w-full flex-wrap gap-0.5 rounded-lg p-0.5">
{PERIODS.map((p, i) => (
<Button
key={p}
type="button"
size="xs"
variant="ghost"
aria-pressed={i === period}
onClick={() => setPeriod(i)}
className={i === period ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}
>
{p}
</Button>
))}
</div>
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'ok', label: 'Успешные (24ч)', value: '38', hint: 'входы и SSO' },
{ id: 'fail', label: 'Неудачи (24ч)', value: '6', hint: 'неверный пароль', variant: 'destructive' },
{ id: 'ip', label: 'Уникальные IP (24ч)', value: '9', hint: '2 новых' },
{ id: 'sess', label: 'Активные сессии', value: String(sessions.length), hint: '3 пользователя' },
]}
/>
<Tabs defaultValue="hist" className="min-w-0">
<TabsList>
<TabsTrigger value="hist">История · 44</TabsTrigger>
<TabsTrigger value="sess">Активные сессии · {sessions.length}</TabsTrigger>
</TabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Журнал входов"
description={`Поиск по логину и IP · период: ${PERIODS[period]}`}
rows={MOCK_LOGINS}
columns={loginCols}
getRowId={(r) => 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}
/>
<StatesFrame
title="Состояния журнала"
description="Пустые и аварийные состояния"
items={[
{ title: 'Нет событий', when: 'за выбранный период входов не было', text: 'За выбранный период входов не было.', cta: 'Сменить период' },
{ title: 'Журнал недоступен', when: 'запрос журнала входов завершился ошибкой', text: 'Не удалось загрузить журнал. Повторите запрос.', tone: 'er', cta: 'Повторить' },
]}
/>
</TabsContent>
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Активные сессии"
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
rows={sessions}
columns={sessionCols}
getRowId={(r) => r.id}
emptyMessage="Нет активных сессий."
/>
</TabsContent>
</Tabs>
<PreviewSheet
open={event !== null}
onOpenChange={(o) => !o && setEvent(null)}
title="Событие входа"
description="Детали записи журнала"
footer={
<>
<Button variant="outline" onClick={() => setEvent(null)}>Закрыть</Button>
<Button
variant="outline"
onClick={() => {
setEvent(null)
void navigate({ to: '/design-preview/users' })
}}
>
Открыть пользователя
</Button>
</>
}
>
{event ? (
<>
<div>
<StatusBadge tone={event.tone}>{event.status}</StatusBadge>
</div>
<KvList
items={[
['Логин', event.login],
['IP', `${event.ip} · AS64500`],
['Клиент', event.client],
['Причина', event.reason],
]}
/>
<CodeBlock label="Событие">{`action: ${event.tone === 'er' ? 'auth.login_failed' : 'auth.login'}\nsource_app: portal\nseverity: ${event.tone === 'er' ? 'warning' : 'info'}\nip: ${event.ip}`}</CodeBlock>
</>
) : null}
</PreviewSheet>
</>
)
}
@@ -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<OidcClientRow[]>(MOCK_OIDC_CLIENTS)
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<OidcClientRow | null>(null)
const [rotating, setRotating] = useState<OidcClientRow | null>(null)
const [secret, setSecret] = useState<string | null>(null)
const columns = useMemo<GridColumn<OidcClientRow>[]>(
() => [
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell title={r.name} sub={r.hint} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> },
{ id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[13px]">{r.clientId}</code> },
{ id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.redirect}</code> },
{ id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="text-sm">{r.secret}</span> },
{ id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.created}</span> },
{
id: 'act',
title: '',
size: 170,
cell: (r) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setRotating(r)
}}
>
<RefreshCwIcon aria-hidden />
Обновить секрет
</Button>
),
},
],
[],
)
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 (
<>
<PageHead
title="OIDC-клиенты"
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
source="discovery и JWKS доступны · подпись RS256"
actions={
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента
</Button>
}
/>
{staleCount > 0 ? (
<SignalAlert tone="wn" title="Секрет клиента Technitium DNS старше 90 дней.">
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
</SignalAlert>
) : null}
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'n', label: 'Клиентов', value: String(clients.length), hint: 'зарегистрировано' },
{ id: 'on', label: 'Включено', value: String(clients.filter((c) => c.enabled).length), hint: 'принимают вход' },
{ id: 'tok', label: 'Токенов за 24 ч', value: '41', hint: 'authorization_code' },
{ id: 'key', label: 'Ключ подписи', value: 'RS256', hint: 'ротация вручную' },
]}
/>
<Tabs defaultValue="clients" className="min-w-0">
<TabsList>
<TabsTrigger value="clients">Клиенты · {clients.length}</TabsTrigger>
<TabsTrigger value="meta">Параметры issuer</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Зарегистрированные клиенты"
description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении"
rows={clients}
columns={columns}
getRowId={(r) => 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}
/>
<StatesFrame
title="Состояния раздела"
description="Пустой список, ошибки redirect URI и метаданных"
items={[
{ title: 'Нет OIDC-клиентов', when: 'ни одно внешнее приложение не зарегистрировано', text: 'Создайте клиента для Technitium DNS или другого OIDC-приложения.', cta: 'Создать клиента' },
{ title: 'Ошибка redirect URI', when: 'redirect URI не https или содержит wildcard', text: 'Укажите точный https-адрес колбэка приложения.', tone: 'wn', cta: 'Исправить' },
{ title: 'Метаданные недоступны', when: 'GET /api/v1/admin/oidc/meta вернул ошибку', text: 'Не удалось загрузить параметры issuer. Повторите запрос.', tone: 'er', cta: 'Повторить' },
]}
/>
</TabsContent>
<TabsContent value="meta" className="flex min-w-0 flex-col gap-4">
<CopyList title="Параметры для подключения приложения" description="Скопируйте в настройки OIDC внешнего приложения" items={OIDC_ENDPOINTS} />
<KvFrame title="Поддерживаемые возможности" description="Из discovery-документа" items={OIDC_CAPABILITIES} />
</TabsContent>
</Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} />
<PreviewSheet
open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)}
title="OIDC-клиент"
description="Параметры клиента"
footer={
<>
<Button variant="outline" onClick={() => setEditing(null)}>Закрыть</Button>
<Button
variant="outline"
onClick={() => {
setEditing(null)
toast.success('Клиент сохранён (макет)')
}}
>
Сохранить
</Button>
</>
}
>
{editing ? (
<>
<Field>
<FieldLabel htmlFor="oc-name">Название</FieldLabel>
<Input id="oc-name" defaultValue={editing.name} />
</Field>
<Field>
<FieldLabel htmlFor="oc-id">client_id</FieldLabel>
<Input id="oc-id" value={editing.clientId} readOnly />
<FieldDescription>Не меняется</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="oc-uri">Redirect URI</FieldLabel>
<Textarea id="oc-uri" defaultValue={editing.redirect} rows={3} />
</Field>
<Field orientation="horizontal">
<Switch id="oc-on" defaultChecked={editing.enabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="oc-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</>
) : null}
</PreviewSheet>
<TypedConfirm
open={rotating !== null}
onOpenChange={(o) => !o && setRotating(null)}
title="Обновить client_secret?"
description="Выпустится новый секрет, старый перестанет работать сразу. После подтверждения новое значение будет показано один раз — скопируйте его в приложение."
expected="обновить"
confirmLabel="Обновить секрет"
onConfirm={() => {
const target = rotating
setRotating(null)
if (target) setClients((c) => c.map((x) => (x.id === target.id ? { ...x, stale: false, secret: 'действует' } : x)))
setSecret(MOCK_CLIENT_SECRET)
}}
/>
<AlertDialog open={secret !== null} onOpenChange={(o) => !o && setSecret(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
<AlertDialogDescription>
Секрет показывается один раз. Потом восстановить его нельзя — только выпустить заново.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center gap-2 rounded-md border p-2">
<code className="min-w-0 flex-1 font-mono text-[13px] break-all">{secret}</code>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Скопировать секрет"
onClick={() => {
void navigator.clipboard?.writeText(secret ?? '').catch(() => undefined)
toast.success('Секрет скопирован')
}}
>
<CopyIcon aria-hidden />
</Button>
</div>
<AlertDialogFooter>
<Button type="button" onClick={() => setSecret(null)}>
Я скопировал секрет
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,240 @@
import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldGroup, 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, StatesFrame, StatusBadge, TypedConfirm } from '../bits'
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
export function SecurityPage() {
const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('Рабочий ноутбук')
const [selected, setSelected] = useState<PasskeyRow | null>(null)
const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<PasskeyRow | null>(null)
const columns = useMemo<GridColumn<PasskeyRow>[]>(
() => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell title={r.name} sub={r.note} /> },
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="text-sm">{r.kind}</span> },
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.added}</span> },
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="text-sm">{r.lastUsed}</span> },
{ id: 'status', title: 'Статус', size: 200, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
],
[],
)
return (
<>
<PageHead
title="Безопасность"
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
source="WebAuthn · домен auth.shnt.top"
actions={
<Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey
</Button>
}
/>
{keys.length === 1 ? (
<SignalAlert tone="wn" title="Один passkey — единственная копия.">
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</SignalAlert>
) : null}
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'keys', label: 'Passkeys', value: String(keys.length), hint: 'активны' },
{ id: 'last', label: 'Последний вход', value: 'сегодня', hint: 'MacBook Touch ID' },
{ id: 'pw', label: 'Пароль', value: 'Задан', hint: 'запасной вход' },
{ id: 'sess', label: 'Сессий', value: '3', hint: 'устройства' },
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="keys">Passkeys · {keys.length}</TabsTrigger>
<TabsTrigger value="pass">Пароль</TabsTrigger>
<TabsTrigger value="acc">Учётная запись</TabsTrigger>
</TabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<PreviewSectionGrid
title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={keys}
columns={columns}
getRowId={(r) => r.id}
views={[
{ label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]}
onRowClick={(r) => {
setSelected(r)
setRenameValue(r.name)
}}
/>
<StatesFrame
title="Состояния раздела"
description="Пустой список и сбои WebAuthn"
items={[
{ title: 'Ключи не зарегистрированы', when: 'у пользователя нет ни одного passkey', text: 'Добавьте passkey, чтобы входить без пароля.', cta: 'Добавить passkey' },
{ title: 'Браузер не поддерживает passkeys', when: 'WebAuthn недоступен (HTTP или старый браузер)', text: 'Откройте портал по HTTPS в современном браузере. Вход по паролю остаётся доступным.', tone: 'wn', cta: 'Понятно' },
{ title: 'Регистрация отменена', when: 'системный диалог закрыт или истёк таймаут', text: 'Passkey не добавлен. Повторите попытку.', tone: 'er', cta: 'Повторить' },
]}
/>
</TabsContent>
<TabsContent value="pass">
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle>Пароль</FrameTitle>
<FrameDescription>Запасной вход, если passkey недоступен</FrameDescription>
</div>
<StatusBadge tone="ok">Задан</StatusBadge>
</FrameHeader>
<FramePanel>
<FieldGroup className="max-w-xl">
<Field>
<FieldLabel htmlFor="pw-cur">Текущий пароль</FieldLabel>
<Input id="pw-cur" type="password" defaultValue="••••••••••" autoComplete="current-password" />
<FieldDescription>Для подтверждения смены · последняя смена 11.03.2026</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="pw-new">Новый пароль</FieldLabel>
<Input id="pw-new" type="password" autoComplete="new-password" />
<FieldDescription>Не короче 12 символов</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="pw-out" defaultChecked />
<div className="flex flex-col">
<FieldLabel htmlFor="pw-out">Завершить остальные сессии</FieldLabel>
<FieldDescription>После смены пароля выйти на всех других устройствах</FieldDescription>
</div>
</Field>
<div>
<Button variant="outline" onClick={() => toast.success('Пароль изменён (макет)')}>
Сменить пароль
</Button>
</div>
</FieldGroup>
</FramePanel>
</Frame>
</TabsContent>
<TabsContent value="acc">
<KvFrame
title="admin@shnt.top"
description="Данные учётной записи"
items={[
{ label: 'Роль', value: 'Администратор' },
{ label: 'Приложений', value: '7 из 7' },
{ label: 'Создана', value: '01.02.2026' },
{ label: 'Последний вход', value: 'сегодня 09:41 · 203.0.113.10' },
{ label: 'Двухфакторная защита', value: `Passkey ×${keys.length}` },
{ label: 'Статус', value: 'Активна' },
]}
/>
</TabsContent>
</Tabs>
<PreviewSheet
open={addOpen}
onOpenChange={setAddOpen}
title="Добавить passkey"
description="Браузер предложит создать ключ для auth.shnt.top"
footer={
<>
<Button variant="outline" onClick={() => setAddOpen(false)}>Отмена</Button>
<Button
onClick={() => {
setKeys((k) => [
...k,
{ id: `pk-${k.length + 1}`, name: newName || 'Новый ключ', note: 'Только что добавлен', kind: 'Платформенный', added: '17.10.2026', lastUsed: 'никогда', tone: 'ok', status: 'Активен' },
])
setAddOpen(false)
toast.success('Passkey добавлен (макет)')
}}
>
<KeyRoundIcon aria-hidden />
Создать ключ
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-name">Название ключа</FieldLabel>
<Input id="pk-name" value={newName} onChange={(e) => setNewName(e.target.value)} />
<FieldDescription>Чтобы отличать устройства в списке</FieldDescription>
</Field>
<SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert>
</PreviewSheet>
<PreviewSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title="Passkey"
description="Название и сведения о ключе"
footer={
<>
<Button
variant="outline"
className="mr-auto"
onClick={() => {
setDeleting(selected)
setSelected(null)
}}
>
<TrashIcon aria-hidden />
Удалить
</Button>
<Button variant="outline" onClick={() => setSelected(null)}>Закрыть</Button>
<Button
variant="outline"
onClick={() => {
setKeys((k) => k.map((x) => (x.id === selected?.id ? { ...x, name: renameValue } : x)))
setSelected(null)
toast.success('Название сохранено')
}}
>
Сохранить
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-rename">Название</FieldLabel>
<Input id="pk-rename" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
</Field>
<KvList
items={[
['Тип', selected?.kind ?? ''],
['Добавлен', `${selected?.added ?? ''} · Chrome 130, macOS`],
['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`],
]}
/>
</PreviewSheet>
<TypedConfirm
open={deleting !== null}
onOpenChange={(o) => !o && setDeleting(null)}
title="Удалить passkey?"
description={`Ключ «${deleting?.name ?? ''}» будет удалён без возможности восстановления. Войти по нему больше не получится.`}
expected="удалить"
confirmLabel="Удалить ключ"
onConfirm={() => {
setKeys((k) => k.filter((x) => x.id !== deleting?.id))
setDeleting(null)
toast.success('Passkey удалён (макет)')
}}
/>
</>
)
}
@@ -0,0 +1,239 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { toast } from 'sonner'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { Button } from '@authportal/ui/components/button'
import { AccessDialog } from '../access-dialog'
import { diffAccess, type AccessValue } from '../access-model'
import { CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatesFrame, TypedConfirm } from '../bits'
import { MOCK_USERS, USER_JOURNAL } from '../mock'
import { usePreviewStore } from '../preview-store'
export function UsersPage() {
const { addChange } = usePreviewStore()
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
const usersRef = useRef(users)
useEffect(() => {
usersRef.current = users
}, [users])
const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [resetFor, setResetFor] = useState<AdminUser | null>(null)
/** Меняет поля у выбранных пользователей и кладёт изменение в «Не сохранено». */
const patch = useCallback(
(ids: string[], apply: (u: AdminUser) => AdminUser, title: string, lineFor: (before: AdminUser, after: AdminUser) => string[]) => {
const before = usersRef.current.filter((u) => ids.includes(u.id))
const after = before.map(apply)
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
addChange({
kind: 'chg',
title,
lines,
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
})
},
[addChange],
)
const onDeactivate = useCallback(
(user: AdminUser) =>
patch([user.id], (u) => ({ ...u, disabled: !u.disabled }), `${user.disabled ? 'Включён' : 'Отключён'} ${user.email}`, (b, a) => [
`− disabled: ${String(b.disabled)}`,
`+ disabled: ${String(a.disabled)}`,
]),
[patch],
)
const onBulkDeactivate = useCallback(
(ids: string[]) =>
patch(ids, (u) => ({ ...u, disabled: true }), `Отключено пользователей: ${ids.length}`, () => ['− disabled: false', '+ disabled: true']),
[patch],
)
const onBulkSetRole = useCallback(
(ids: string[], isAdmin: boolean) =>
patch(ids, (u) => ({ ...u, is_admin: isAdmin }), `Роль «${isAdmin ? 'Админ' : 'Пользователь'}» для ${ids.length}`, (b) => [
`− is_admin: ${String(b.is_admin)}`,
`+ is_admin: ${String(isAdmin)}`,
]),
[patch],
)
const onCreate = useCallback(
(values: CreateUserRequest) => {
const created: AdminUser = {
id: `u-new-${Date.now()}`,
email: values.email,
name: values.name,
is_admin: values.is_admin,
disabled: false,
apps: [],
permissions: [],
last_login_at: null,
last_login_ip: null,
passkey_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setUsers((prev) => [...prev, created])
addChange({
kind: 'add',
title: `Создан ${created.email}`,
lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`],
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)),
})
setCreateOpen(false)
setAccessFor(created)
},
[addChange],
)
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 admins = users.filter((u) => u.is_admin).length
const noPasskey = users.filter((u) => u.passkey_count === 0).length
const disabled = users.filter((u) => u.disabled).length
return (
<>
<PageHead
title="Пользователи"
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
source={`GET /api/v1/admin/users · ${users.length} учётных записей`}
/>
<SignalAlert
tone="er"
title="Подозрительная активность: ops@shnt.top."
action={
<Button variant="outline" size="sm" render={<Link to="/design-preview/logins" />} nativeButton={false}>
Показать
</Button>
}
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись.
</SignalAlert>
<KpiStatGrid
aria-label="Показатели"
items={[
{ id: 'total', label: 'Пользователей', value: String(users.length), hint: 'в каталоге' },
{ id: 'admins', label: 'Администраторов', value: String(admins), hint: 'is_admin', variant: 'warning' },
{ id: 'nopk', label: 'Без passkey', value: String(noPasskey), hint: 'только пароль', variant: 'warning' },
{ id: 'off', label: 'Отключённых', value: String(disabled), hint: 'вход закрыт' },
]}
/>
<AdminUsersGrid
users={users}
onCreateClick={() => setCreateOpen(true)}
onOpenAudit={onOpenAudit}
onOpenAccess={onOpenAccess}
onDeactivate={onDeactivate}
onDelete={onDelete}
onResetPasskeys={onResetPasskeys}
onBulkSetRole={onBulkSetRole}
onBulkDeactivate={onBulkDeactivate}
/>
<StatesFrame
title="Состояния раздела"
description="Пустые и аварийные состояния каталога"
items={[
{ title: 'Нет пользователей', when: 'каталог пуст — первый вход ещё не выполнен', text: 'Создайте первого пользователя и выдайте ему доступ к приложениям.', cta: 'Создать пользователя' },
{ title: 'Нельзя отключить себя', when: 'попытка отключить собственную запись или последнего администратора', text: 'В портале должен остаться хотя бы один активный администратор.', tone: 'wn', cta: 'Понятно' },
{ title: 'Каталог недоступен', when: 'GET /api/v1/admin/users завершился ошибкой', text: 'Ошибка загрузки. Повторите запрос.', tone: 'er', cta: 'Повторить' },
]}
/>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
{accessFor ? (
<AccessDialog
key={accessFor.id}
user={accessFor}
onClose={() => setAccessFor(null)}
onSave={(value: AccessValue) => {
const target = accessFor
patch(
[target.id],
(u) => ({ ...u, apps: value.apps, permissions: value.permissions }),
`Права обновлены: ${target.email}`,
() => diffAccess({ apps: target.apps, permissions: target.permissions }, value),
)
}}
/>
) : null}
<PreviewSheet
open={journalFor !== null}
onOpenChange={(o) => !o && setJournalFor(null)}
title="Журнал пользователя"
description="Последние события пользователя из журнала портала"
footer={<Button variant="outline" onClick={() => setJournalFor(null)}>Закрыть</Button>}
>
<KvList
items={[
['Email', journalFor?.email ?? ''],
['Последний вход', `${journalFor?.last_login_ip ?? '—'}`],
['Passkeys', String(journalFor?.passkey_count ?? 0)],
]}
/>
<CodeBlock label={`${journalFor?.email ?? ''} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</PreviewSheet>
<TypedConfirm
open={deleteFor !== null}
onOpenChange={(o) => !o && setDeleteFor(null)}
title="Удалить пользователя?"
description={`Учётная запись ${deleteFor?.email ?? ''} и её права будут удалены. Действие попадёт в панель «Не сохранено» и в журнал.`}
expected={deleteFor?.email ?? ''}
confirmLabel="Удалить"
onConfirm={() => {
const target = deleteFor
if (!target) return
const index = usersRef.current.findIndex((u) => u.id === target.id)
setUsers((prev) => prev.filter((u) => u.id !== target.id))
addChange({
kind: 'del',
title: `Удалён ${target.email}`,
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
revert: () =>
setUsers((prev) => {
const copy = [...prev]
copy.splice(Math.min(index, copy.length), 0, target)
return copy
}),
})
setDeleteFor(null)
}}
/>
<TypedConfirm
open={resetFor !== null}
onOpenChange={(o) => !o && setResetFor(null)}
title="Сбросить passkeys пользователя?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
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)
}}
/>
</>
)
}
@@ -0,0 +1,564 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import {
ActivityIcon,
CheckIcon,
ChevronsUpDownIcon,
CloudIcon,
ContrastIcon,
FingerprintIcon,
GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
RowsIcon,
SearchIcon,
ServerIcon,
ShieldIcon,
SunIcon,
UserPlusIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { Badge } from '@/components/reui/badge'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@authportal/ui/components/breadcrumb'
import { Button } from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Kbd } from '@authportal/ui/components/kbd'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { Separator } from '@authportal/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS } from './mock'
import { usePreviewStore } from './preview-store'
const APP_ICON: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: GlobeIcon,
fw: ShieldIcon,
dns: GlobeIcon,
cdn: CloudIcon,
mm: LayoutDashboardIcon,
}
function PreviewAppSwitcher() {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />}
>
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden>
<KeyRoundIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Auth Portal</span>
<span className="text-muted-foreground truncate text-xs">auth.shnt.top</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
<DropdownMenuItem disabled>
<KeyRoundIcon />
Auth Portal
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICON[app.id]
return (
<DropdownMenuItem
key={app.id}
onClick={() => toast(`Открыть ${app.title}`, { description: `${app.url} · SSO handoff (макет)` })}
>
<Icon />
{app.title}
<span className="text-muted-foreground ml-auto font-mono text-[11px]">{app.id}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
const THEMES = [
{ value: 'light', label: 'Светлая', icon: SunIcon },
{ value: 'dark', label: 'Тёмная', icon: MoonIcon },
{ value: 'system', label: 'Системная', icon: MonitorIcon },
] as const
function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: ReadonlyArray<{ value: T; label: string; icon?: typeof SunIcon }>
onChange: (v: T) => void
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{options.map((o) => {
const active = value === o.value
const Icon = o.icon
return (
<Button
key={o.value}
type="button"
role="radio"
aria-checked={active}
aria-label={o.label}
title={o.label}
variant="ghost"
size={Icon ? 'icon-xs' : 'xs'}
onClick={() => onChange(o.value)}
className={cn(
'rounded-full',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{Icon ? <Icon className="size-3.5" aria-hidden /> : o.label}
</Button>
)
})}
</div>
)
}
function PreviewNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const { density, setDensity, contrast, setContrast } = usePreviewStore()
const navigate = useNavigate()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
aria-label="Профиль: Admin, admin@shnt.top"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-64 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-foreground flex items-center gap-2 py-2 font-normal">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/account' })}>
<FingerprintIcon aria-hidden />
Безопасность
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<Segmented
label="Тема"
value={(mounted ? theme : 'system') ?? 'system'}
options={THEMES}
onChange={setTheme}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<RowsIcon aria-hidden />
Плотность
<div className="ml-auto">
<Segmented
label="Плотность"
value={density}
options={[
{ value: 'comfortable', label: 'Обычная' },
{ value: 'compact', label: 'Компактная' },
]}
onChange={setDensity}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<ContrastIcon aria-hidden />
Контраст
<div className="ml-auto">
<Segmented
label="Контраст"
value={contrast}
options={[
{ value: 'standard', label: 'Обычный' },
{ value: 'high', label: 'Высокий' },
]}
onChange={setContrast}
/>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/login' })}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
export function PreviewSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<PreviewAppSwitcher />
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? (
<SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" />
) : null}
<SidebarGroup>
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.filter((i) => i.group === group).map((item) => {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
render={<Link to={item.to} />}
>
<span className="relative flex shrink-0">
<Icon className="size-4" />
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</span>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PreviewNavUser />
</SidebarFooter>
</Sidebar>
)
}
function MonitorPopover() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор Auth Portal: внимание, 1 ошибка, 1 предупреждение"
className="border-border hover:bg-accent focus-visible:ring-ring inline-flex h-8 items-center gap-1.5 rounded-md border px-2 outline-none focus-visible:ring-2"
/>
}
>
<ActivityIcon aria-hidden className="text-destructive size-3.5" />
<span className="text-foreground hidden text-xs font-medium @2xl/header:inline">Система</span>
<Badge variant="destructive-light" size="xs" className="h-4 px-1.5 text-[10px]">
{bad.length}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-xs font-medium">Монитор Auth Portal</span>
<Badge variant="warning-light" size="xs">Внимание</Badge>
</div>
<ul className="flex flex-col">
{MONITOR_ITEMS.map((m) => (
<li key={m.id} className="border-border border-b last:border-b-0">
<Link to={m.to} className="hover:bg-accent flex items-start gap-2.5 px-3 py-2.5 outline-none focus-visible:bg-accent">
<span
aria-hidden
className={cn(
'mt-1.5 size-2 shrink-0 rounded-full',
m.tone === 'er' ? 'bg-destructive' : m.tone === 'wn' ? 'bg-warning' : 'bg-success',
)}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium">{m.title}</span>
<span className="text-muted-foreground text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground text-[11px] whitespace-nowrap">{m.age}</span>
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
)
}
export function PreviewHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = usePreviewStore()
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background @container/header sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="hidden @lg/header:inline-flex">Auth Portal</BreadcrumbItem>
<BreadcrumbSeparator className="hidden @lg/header:block" />
<BreadcrumbItem className="hidden @md/header:inline-flex">{current?.group ?? 'Портал'}</BreadcrumbItem>
<BreadcrumbSeparator className="hidden @md/header:block" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{current?.label ?? 'Макет'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
aria-label="Командная палитра"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
<Kbd className="hidden @xl/header:inline-flex">⌘K</Kbd>
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
run: () => void
}
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = usePreviewStore()
const navigate = useNavigate()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const entries = useMemo<PaletteEntry[]>(() => {
const go = (to: string) => () => void navigate({ to })
return [
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
{ id: 'act-user', group: 'Действия', label: 'Создать пользователя', hint: 'Пользователи', run: go('/design-preview/users') },
{ id: 'act-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') },
{ id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') },
]
}, [navigate])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
return q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries
}, [entries, query])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[20%] w-[min(40rem,calc(100vw-2rem))] max-w-none translate-y-0 gap-0 p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2 border-b px-3">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="h-11 min-w-0 flex-1 bg-transparent text-sm outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[50svh] overflow-y-auto p-1.5" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{group}</div>
{list.map((e) => (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm outline-none',
e.index === active && 'bg-accent text-accent-foreground',
)}
>
{group === 'Действия' ? <UserPlusIcon className="size-4" aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate">{e.label}</span>
<span className="text-muted-foreground truncate text-xs">{e.hint}</span>
</button>
))}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-3 py-2 text-xs">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { toast } from 'sonner'
import {
PreviewStoreContext,
type Contrast,
type Density,
type PendingChange,
type PreviewStore,
} from './preview-store'
/**
* Состояние макета: «Не сохранено», палитра, плотность и контраст.
* Атрибуты html[data-preview-density|contrast] ставятся только пока макет смонтирован.
*/
export function PreviewProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingChange[]>([])
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensity] = useState<Density>('comfortable')
const [contrast, setContrast] = useState<Contrast>('standard')
const seq = useRef(0)
const pendingRef = useRef(pending)
useEffect(() => {
pendingRef.current = pending
}, [pending])
useEffect(() => {
const root = document.documentElement
root.dataset.previewDensity = density
root.dataset.previewContrast = contrast
return () => {
delete root.dataset.previewDensity
delete root.dataset.previewContrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const addChange = useCallback((change: Omit<PendingChange, 'id'>) => {
seq.current += 1
setPending((prev) => [...prev, { ...change, id: `chg-${seq.current}` }])
}, [])
const discardAll = useCallback(() => {
const list = pendingRef.current
for (let i = list.length - 1; i >= 0; i -= 1) list[i]?.revert?.()
setPending([])
toast('Изменения отменены')
}, [])
const applyAll = useCallback(() => {
const count = pendingRef.current.length
setPending([])
toast.success(`Сохранено изменений: ${count}`, {
description: 'Макет: записи в журнал не отправляются.',
})
}, [])
const value = useMemo<PreviewStore>(
() => ({
pending,
addChange,
discardAll,
applyAll,
paletteOpen,
setPaletteOpen,
density,
setDensity,
contrast,
setContrast,
}),
[pending, addChange, discardAll, applyAll, paletteOpen, density, contrast],
)
return (
<PreviewStoreContext.Provider value={value}>
{children}
</PreviewStoreContext.Provider>
)
}
@@ -0,0 +1,44 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
import { UnsavedBar } from './unsaved-bar'
import './design-preview.css'
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
export function PreviewShell({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) {
return (
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<TooltipProvider delay={0}>
<SidebarProvider style={{ '--sidebar-width': '240px' } as CSSProperties}>
<a
href="#main-content"
className="bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:ring-2"
>
К содержимому
</a>
<PreviewSidebar />
<SidebarInset id="main-content" className="min-w-0">
<PreviewHeader />
<div className="flex min-w-0 flex-1 flex-col gap-4 px-4 py-4 pb-24 md:gap-6 md:px-6 md:py-5 md:pb-24">
{children}
</div>
</SidebarInset>
<CommandPalette />
<UnsavedBar />
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,37 @@
import { createContext, useContext } from 'react'
export type ChangeKind = 'add' | 'chg' | 'del'
export type PendingChange = {
id: string
kind: ChangeKind
title: string
/** Строки diff: «+ …», «− …», « … ». */
lines: string[]
/** Откатывает локальное состояние страницы при «Отменить». */
revert?: () => void
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type PreviewStore = {
pending: PendingChange[]
addChange: (change: Omit<PendingChange, 'id'>) => void
discardAll: () => void
applyAll: () => void
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const PreviewStoreContext = createContext<PreviewStore | null>(null)
export function usePreviewStore(): PreviewStore {
const ctx = useContext(PreviewStoreContext)
if (!ctx) throw new Error('PreviewProvider отсутствует')
return ctx
}
@@ -0,0 +1,255 @@
import { useMemo, useState, type ReactNode } from 'react'
import { SearchIcon } from 'lucide-react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Separator } from '@authportal/ui/components/separator'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
const SELECT_COLUMN_SIZE = 44
/**
* Секция-таблица макета: Frame + Filters-подобная панель + DataGrid на DataGridScrollArea.
* Минимальная ширина таблицы = сумма колонок, поэтому на узком экране таблица
* прокручивается по горизонтали внутри контейнера, а не ломает страницу.
*/
export function PreviewSectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = false,
bulkActions,
toolbar,
pageSize = 8,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode
toolbar?: ReactNode
pageSize?: number
emptyMessage?: string
className?: string
}) {
const [view, setView] = useState(0)
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const defs = useMemo<ColumnDef<T>[]>(() => {
const list: ColumnDef<T>[] = []
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
})
}
for (const col of columns) {
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title },
})
}
return list
}, [columns, selectable])
const table = useReactTable({
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const minWidth = columns.reduce((sum, c) => sum + c.size, selectable ? SELECT_COLUMN_SIZE : 0)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const paged = data.length > pageSize
return (
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: true, width: 'auto' }}
>
<Frame spacing="sm" className={cn('w-full min-w-0', className)}>
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none">
{views || searchText || toolbar ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{views ? (
<div role="group" aria-label="Вид" className="bg-muted/60 inline-flex flex-wrap gap-0.5 rounded-lg p-0.5">
{views.map((v, i) => (
<Button
key={v.label}
type="button"
size="xs"
variant="ghost"
aria-pressed={i === view}
onClick={() => {
setView(i)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
className={cn(
i === view
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{v.label}
</Button>
))}
</div>
) : null}
{searchText ? (
<InputGroup className="w-56 max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
</div>
{toolbar ? <div className="flex flex-wrap items-center gap-2">{toolbar}</div> : null}
</div>
<Separator />
</>
) : null}
{selectable && selectedIds.length > 0 && bulkActions ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2">
<span className="text-sm font-medium">Выбрано: {selectedIds.length}</span>
<div className="flex flex-wrap items-center gap-2">
{bulkActions(selectedIds, () => setRowSelection({}))}
<Button type="button" size="sm" variant="ghost" onClick={() => setRowSelection({})}>
Сбросить
</Button>
</div>
</div>
<Separator />
</>
) : null}
<DataGridScrollArea>
<div style={{ minWidth }}>
<DataGridTable />
</div>
</DataGridScrollArea>
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
{paged ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub }: { title: ReactNode; sub?: ReactNode }) {
return (
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate text-xs">{sub}</span> : null}
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { Tone } from './mock'
type BadgeVariant =
| 'success-light'
| 'warning-light'
| 'destructive-light'
| 'info-light'
| 'outline'
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
mu: 'outline',
}
export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-info',
mu: 'bg-muted-foreground/50',
}
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Badge } from '@/components/reui/badge'
import { Button } from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@authportal/ui/components/dialog'
import { cn } from '@authportal/ui/lib/utils'
import { usePreviewStore } from './preview-store'
function lineClass(line: string) {
if (line.startsWith('+')) return 'bg-success/10 text-success-foreground'
if (line.startsWith('−') || line.startsWith('-')) return 'bg-destructive/10 text-destructive-foreground'
return 'text-muted-foreground'
}
/** Плавающая панель «Не сохранено» с просмотром diff. */
export function UnsavedBar() {
const { pending, discardAll, applyAll } = usePreviewStore()
const [diffOpen, setDiffOpen] = useState(false)
if (pending.length === 0) return null
return (
<>
<div
role="region"
aria-label="Несохранённые изменения"
className="bg-popover text-popover-foreground fixed inset-x-4 bottom-4 z-30 mx-auto flex max-w-3xl flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border p-3 shadow-lg"
>
<Badge variant="warning-light" size="sm">Не сохранено</Badge>
<span className="min-w-0 flex-1 basis-48 text-sm">
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setDiffOpen(true)}>
Просмотреть diff
</Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}>
Отменить
</Button>
<Button type="button" size="sm" onClick={applyAll}>
Сохранить
</Button>
</div>
</div>
<Dialog open={diffOpen} onOpenChange={setDiffOpen}>
<DialogContent className="w-[min(40rem,calc(100vw-2rem))] max-w-none sm:max-w-none">
<DialogHeader>
<DialogTitle>Что изменится</DialogTitle>
<DialogDescription>
Проверьте различия перед сохранением. Каждое изменение попадёт в журнал «Изменения».
</DialogDescription>
</DialogHeader>
<div className="flex max-h-[50svh] flex-col gap-3 overflow-y-auto">
{pending.map((c) => (
<section key={c.id} className="flex flex-col gap-1">
<h3 className="text-sm font-medium">{c.title}</h3>
<pre className="overflow-x-auto rounded-md border font-mono text-xs">
{c.lines.map((l, i) => (
<div key={`${c.id}-${i}`} className={cn('px-3 py-0.5 whitespace-pre', lineClass(l))}>
{l}
</div>
))}
</pre>
</section>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDiffOpen(false)}>
Закрыть
</Button>
<Button
type="button"
onClick={() => {
setDiffOpen(false)
applyAll()
}}
>
Сохранить {pending.length}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+246
View File
@@ -11,15 +11,26 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as DesignPreviewRouteImport } from './routes/design-preview'
import { Route as LogoutRouteImport } from './routes/logout'
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
import { Route as DesignPreviewIndexRouteImport } from './routes/design-preview.index'
import { Route as DesignPreviewShellRouteImport } from './routes/design-preview._shell'
import { Route as DesignPreviewLoginRouteImport } from './routes/design-preview.login'
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
import { Route as AuthAdminOidcRouteImport } from './routes/_auth.admin.oidc'
import { Route as DesignPreviewShellAccountRouteImport } from './routes/design-preview._shell.account'
import { Route as DesignPreviewShellAppLinksRouteImport } from './routes/design-preview._shell.app-links'
import { Route as DesignPreviewShellAppsRouteImport } from './routes/design-preview._shell.apps'
import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit'
import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins'
import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc'
import { Route as DesignPreviewShellUsersRouteImport } from './routes/design-preview._shell.users'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
const IndexRoute = IndexRouteImport.update({
@@ -31,6 +42,11 @@ const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const DesignPreviewRoute = DesignPreviewRouteImport.update({
id: '/design-preview',
path: '/design-preview',
getParentRoute: () => rootRouteImport,
} as any)
const LogoutRoute = LogoutRouteImport.update({
id: '/logout',
path: '/logout',
@@ -51,6 +67,20 @@ const AuthAppsRoute = AuthAppsRouteImport.update({
path: '/apps',
getParentRoute: () => AuthRoute,
} as any)
const DesignPreviewIndexRoute = DesignPreviewIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => DesignPreviewRoute,
} as any)
const DesignPreviewShellRoute = DesignPreviewShellRouteImport.update({
id: '/_shell',
getParentRoute: () => DesignPreviewRoute,
} as any)
const DesignPreviewLoginRoute = DesignPreviewLoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => DesignPreviewRoute,
} as any)
const AuthAdminIndexRoute = AuthAdminIndexRouteImport.update({
id: '/',
path: '/',
@@ -76,6 +106,44 @@ const AuthAdminOidcRoute = AuthAdminOidcRouteImport.update({
path: '/oidc',
getParentRoute: () => AuthAdminRoute,
} as any)
const DesignPreviewShellAccountRoute =
DesignPreviewShellAccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellAppLinksRoute =
DesignPreviewShellAppLinksRouteImport.update({
id: '/app-links',
path: '/app-links',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellAppsRoute = DesignPreviewShellAppsRouteImport.update({
id: '/apps',
path: '/apps',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellAuditRoute = DesignPreviewShellAuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellLoginsRoute =
DesignPreviewShellLoginsRouteImport.update({
id: '/logins',
path: '/logins',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellOidcRoute = DesignPreviewShellOidcRouteImport.update({
id: '/oidc',
path: '/oidc',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const DesignPreviewShellUsersRoute = DesignPreviewShellUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => DesignPreviewShellRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
@@ -84,14 +152,24 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/design-preview': typeof DesignPreviewRouteWithChildren
'/logout': typeof LogoutRoute
'/account': typeof AuthAccountRoute
'/admin': typeof AuthAdminRouteWithChildren
'/apps': typeof AuthAppsRoute
'/design-preview/login': typeof DesignPreviewLoginRoute
'/design-preview/': typeof DesignPreviewIndexRoute
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin/oidc': typeof AuthAdminOidcRoute
'/design-preview/account': typeof DesignPreviewShellAccountRoute
'/design-preview/app-links': typeof DesignPreviewShellAppLinksRoute
'/design-preview/apps': typeof DesignPreviewShellAppsRoute
'/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -100,10 +178,19 @@ export interface FileRoutesByTo {
'/logout': typeof LogoutRoute
'/account': typeof AuthAccountRoute
'/apps': typeof AuthAppsRoute
'/design-preview': typeof DesignPreviewIndexRoute
'/design-preview/login': typeof DesignPreviewLoginRoute
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin/oidc': typeof AuthAdminOidcRoute
'/design-preview/account': typeof DesignPreviewShellAccountRoute
'/design-preview/app-links': typeof DesignPreviewShellAppLinksRoute
'/design-preview/apps': typeof DesignPreviewShellAppsRoute
'/design-preview/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/users': typeof DesignPreviewShellUsersRoute
'/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -111,14 +198,25 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/design-preview': typeof DesignPreviewRouteWithChildren
'/logout': typeof LogoutRoute
'/_auth/account': typeof AuthAccountRoute
'/_auth/admin': typeof AuthAdminRouteWithChildren
'/_auth/apps': typeof AuthAppsRoute
'/design-preview/_shell': typeof DesignPreviewShellRouteWithChildren
'/design-preview/login': typeof DesignPreviewLoginRoute
'/design-preview/': typeof DesignPreviewIndexRoute
'/_auth/admin/apps': typeof AuthAdminAppsRoute
'/_auth/admin/audit': typeof AuthAdminAuditRoute
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
'/_auth/admin/oidc': typeof AuthAdminOidcRoute
'/design-preview/_shell/account': typeof DesignPreviewShellAccountRoute
'/design-preview/_shell/app-links': typeof DesignPreviewShellAppLinksRoute
'/design-preview/_shell/apps': typeof DesignPreviewShellAppsRoute
'/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute
'/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
'/design-preview/_shell/users': typeof DesignPreviewShellUsersRoute
'/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -126,14 +224,24 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/design-preview'
| '/logout'
| '/account'
| '/admin'
| '/apps'
| '/design-preview/login'
| '/design-preview/'
| '/admin/apps'
| '/admin/audit'
| '/admin/logins'
| '/admin/oidc'
| '/design-preview/account'
| '/design-preview/app-links'
| '/design-preview/apps'
| '/design-preview/audit'
| '/design-preview/logins'
| '/design-preview/oidc'
| '/design-preview/users'
| '/admin/'
| '/admin/users/$userId'
fileRoutesByTo: FileRoutesByTo
@@ -142,24 +250,44 @@ export interface FileRouteTypes {
| '/logout'
| '/account'
| '/apps'
| '/design-preview'
| '/design-preview/login'
| '/admin/apps'
| '/admin/audit'
| '/admin/logins'
| '/admin/oidc'
| '/design-preview/account'
| '/design-preview/app-links'
| '/design-preview/apps'
| '/design-preview/audit'
| '/design-preview/logins'
| '/design-preview/oidc'
| '/design-preview/users'
| '/admin'
| '/admin/users/$userId'
id:
| '__root__'
| '/'
| '/_auth'
| '/design-preview'
| '/logout'
| '/_auth/account'
| '/_auth/admin'
| '/_auth/apps'
| '/design-preview/_shell'
| '/design-preview/login'
| '/design-preview/'
| '/_auth/admin/apps'
| '/_auth/admin/audit'
| '/_auth/admin/logins'
| '/_auth/admin/oidc'
| '/design-preview/_shell/account'
| '/design-preview/_shell/app-links'
| '/design-preview/_shell/apps'
| '/design-preview/_shell/audit'
| '/design-preview/_shell/logins'
| '/design-preview/_shell/oidc'
| '/design-preview/_shell/users'
| '/_auth/admin/'
| '/_auth/admin/users/$userId'
fileRoutesById: FileRoutesById
@@ -167,6 +295,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
DesignPreviewRoute: typeof DesignPreviewRouteWithChildren
LogoutRoute: typeof LogoutRoute
}
@@ -186,6 +315,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/design-preview': {
id: '/design-preview'
path: '/design-preview'
fullPath: '/design-preview'
preLoaderRoute: typeof DesignPreviewRouteImport
parentRoute: typeof rootRouteImport
}
'/logout': {
id: '/logout'
path: '/logout'
@@ -214,6 +350,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAppsRouteImport
parentRoute: typeof AuthRoute
}
'/design-preview/': {
id: '/design-preview/'
path: '/'
fullPath: '/design-preview/'
preLoaderRoute: typeof DesignPreviewIndexRouteImport
parentRoute: typeof DesignPreviewRoute
}
'/design-preview/_shell': {
id: '/design-preview/_shell'
path: ''
fullPath: '/design-preview'
preLoaderRoute: typeof DesignPreviewShellRouteImport
parentRoute: typeof DesignPreviewRoute
}
'/design-preview/login': {
id: '/design-preview/login'
path: '/login'
fullPath: '/design-preview/login'
preLoaderRoute: typeof DesignPreviewLoginRouteImport
parentRoute: typeof DesignPreviewRoute
}
'/_auth/admin/': {
id: '/_auth/admin/'
path: '/'
@@ -249,6 +406,55 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminOidcRouteImport
parentRoute: typeof AuthAdminRoute
}
'/design-preview/_shell/account': {
id: '/design-preview/_shell/account'
path: '/account'
fullPath: '/design-preview/account'
preLoaderRoute: typeof DesignPreviewShellAccountRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/design-preview/_shell/app-links': {
id: '/design-preview/_shell/app-links'
path: '/app-links'
fullPath: '/design-preview/app-links'
preLoaderRoute: typeof DesignPreviewShellAppLinksRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/design-preview/_shell/apps': {
id: '/design-preview/_shell/apps'
path: '/apps'
fullPath: '/design-preview/apps'
preLoaderRoute: typeof DesignPreviewShellAppsRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/design-preview/_shell/audit': {
id: '/design-preview/_shell/audit'
path: '/audit'
fullPath: '/design-preview/audit'
preLoaderRoute: typeof DesignPreviewShellAuditRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/design-preview/_shell/logins': {
id: '/design-preview/_shell/logins'
path: '/logins'
fullPath: '/design-preview/logins'
preLoaderRoute: typeof DesignPreviewShellLoginsRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/design-preview/_shell/oidc': {
id: '/design-preview/_shell/oidc'
path: '/oidc'
fullPath: '/design-preview/oidc'
preLoaderRoute: typeof DesignPreviewShellOidcRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/design-preview/_shell/users': {
id: '/design-preview/_shell/users'
path: '/users'
fullPath: '/design-preview/users'
preLoaderRoute: typeof DesignPreviewShellUsersRouteImport
parentRoute: typeof DesignPreviewShellRoute
}
'/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId'
path: '/users/$userId'
@@ -295,9 +501,49 @@ const AuthRouteChildren: AuthRouteChildren = {
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface DesignPreviewShellRouteChildren {
DesignPreviewShellAccountRoute: typeof DesignPreviewShellAccountRoute
DesignPreviewShellAppLinksRoute: typeof DesignPreviewShellAppLinksRoute
DesignPreviewShellAppsRoute: typeof DesignPreviewShellAppsRoute
DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
DesignPreviewShellUsersRoute: typeof DesignPreviewShellUsersRoute
}
const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
DesignPreviewShellAccountRoute: DesignPreviewShellAccountRoute,
DesignPreviewShellAppLinksRoute: DesignPreviewShellAppLinksRoute,
DesignPreviewShellAppsRoute: DesignPreviewShellAppsRoute,
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
DesignPreviewShellUsersRoute: DesignPreviewShellUsersRoute,
}
const DesignPreviewShellRouteWithChildren =
DesignPreviewShellRoute._addFileChildren(DesignPreviewShellRouteChildren)
interface DesignPreviewRouteChildren {
DesignPreviewShellRoute: typeof DesignPreviewShellRouteWithChildren
DesignPreviewLoginRoute: typeof DesignPreviewLoginRoute
DesignPreviewIndexRoute: typeof DesignPreviewIndexRoute
}
const DesignPreviewRouteChildren: DesignPreviewRouteChildren = {
DesignPreviewShellRoute: DesignPreviewShellRouteWithChildren,
DesignPreviewLoginRoute: DesignPreviewLoginRoute,
DesignPreviewIndexRoute: DesignPreviewIndexRoute,
}
const DesignPreviewRouteWithChildren = DesignPreviewRoute._addFileChildren(
DesignPreviewRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
DesignPreviewRoute: DesignPreviewRouteWithChildren,
LogoutRoute: LogoutRoute,
}
export const routeTree = rootRouteImport
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { SecurityPage } from '@/design-preview/pages/security-page'
export const Route = createFileRoute('/design-preview/_shell/account')({
component: SecurityPage,
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppLinksPage } from '@/design-preview/pages/app-links-page'
export const Route = createFileRoute('/design-preview/_shell/app-links')({
component: AppLinksPage,
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppsPage } from '@/design-preview/pages/apps-page'
export const Route = createFileRoute('/design-preview/_shell/apps')({
component: AppsPage,
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AuditPage } from '@/design-preview/pages/audit-page'
export const Route = createFileRoute('/design-preview/_shell/audit')({
component: AuditPage,
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { LoginsPage } from '@/design-preview/pages/logins-page'
export const Route = createFileRoute('/design-preview/_shell/logins')({
component: LoginsPage,
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { OidcPage } from '@/design-preview/pages/oidc-page'
export const Route = createFileRoute('/design-preview/_shell/oidc')({
component: OidcPage,
})
@@ -0,0 +1,14 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
import { PreviewShell } from '@/design-preview/preview-shell'
export const Route = createFileRoute('/design-preview/_shell')({
component: ShellLayout,
})
function ShellLayout() {
return (
<PreviewShell>
<Outlet />
</PreviewShell>
)
}
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { UsersPage } from '@/design-preview/pages/users-page'
export const Route = createFileRoute('/design-preview/_shell/users')({
component: UsersPage,
})
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/design-preview/')({
beforeLoad: () => {
throw redirect({ to: '/design-preview/apps' })
},
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { LoginPage } from '@/design-preview/pages/login-page'
export const Route = createFileRoute('/design-preview/login')({
component: LoginPage,
})
+15
View File
@@ -0,0 +1,15 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
import { PreviewProvider } from '@/design-preview/preview-provider'
/** Живой макет на суррогатных данных: без авторизации и без запросов к API. */
export const Route = createFileRoute('/design-preview')({
component: DesignPreviewLayout,
})
function DesignPreviewLayout() {
return (
<PreviewProvider>
<Outlet />
</PreviewProvider>
)
}