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:
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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user