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')
|
const reason = detailString(event.details, 'reason')
|
||||||
|
|
||||||
return (
|
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">
|
<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" />
|
<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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -145,8 +145,8 @@ function EventRow({
|
|||||||
</TimelineIndicator>
|
</TimelineIndicator>
|
||||||
</TimelineHeader>
|
</TimelineHeader>
|
||||||
|
|
||||||
<TimelineContent className="mt-2">
|
<TimelineContent className="mt-2 min-w-0">
|
||||||
<Frame stacked dense spacing="sm">
|
<Frame stacked dense spacing="sm" className="min-w-0">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
@@ -154,10 +154,10 @@ function EventRow({
|
|||||||
>
|
>
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full"
|
className="flex w-full min-w-0"
|
||||||
aria-label={`Подробности: ${meta.label}`}
|
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">
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
<Avatar className="size-5">
|
<Avatar className="size-5">
|
||||||
<AvatarFallback className="text-[10px]">
|
<AvatarFallback className="text-[10px]">
|
||||||
@@ -327,7 +327,7 @@ export function AuditLogTimeline({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Select
|
||||||
value={source}
|
value={source}
|
||||||
onValueChange={(value) => {
|
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 rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
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 LogoutRouteImport } from './routes/logout'
|
||||||
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
|
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
|
||||||
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
||||||
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
|
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 AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
||||||
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
||||||
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
|
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
|
||||||
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
|
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
|
||||||
import { Route as AuthAdminOidcRouteImport } from './routes/_auth.admin.oidc'
|
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'
|
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -31,6 +42,11 @@ const AuthRoute = AuthRouteImport.update({
|
|||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DesignPreviewRoute = DesignPreviewRouteImport.update({
|
||||||
|
id: '/design-preview',
|
||||||
|
path: '/design-preview',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LogoutRoute = LogoutRouteImport.update({
|
const LogoutRoute = LogoutRouteImport.update({
|
||||||
id: '/logout',
|
id: '/logout',
|
||||||
path: '/logout',
|
path: '/logout',
|
||||||
@@ -51,6 +67,20 @@ const AuthAppsRoute = AuthAppsRouteImport.update({
|
|||||||
path: '/apps',
|
path: '/apps',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} 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({
|
const AuthAdminIndexRoute = AuthAdminIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -76,6 +106,44 @@ const AuthAdminOidcRoute = AuthAdminOidcRouteImport.update({
|
|||||||
path: '/oidc',
|
path: '/oidc',
|
||||||
getParentRoute: () => AuthAdminRoute,
|
getParentRoute: () => AuthAdminRoute,
|
||||||
} as any)
|
} 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({
|
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||||
id: '/users/$userId',
|
id: '/users/$userId',
|
||||||
path: '/users/$userId',
|
path: '/users/$userId',
|
||||||
@@ -84,14 +152,24 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/design-preview': typeof DesignPreviewRouteWithChildren
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/account': typeof AuthAccountRoute
|
'/account': typeof AuthAccountRoute
|
||||||
'/admin': typeof AuthAdminRouteWithChildren
|
'/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
|
'/design-preview/login': typeof DesignPreviewLoginRoute
|
||||||
|
'/design-preview/': typeof DesignPreviewIndexRoute
|
||||||
'/admin/apps': typeof AuthAdminAppsRoute
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
'/admin/audit': typeof AuthAdminAuditRoute
|
'/admin/audit': typeof AuthAdminAuditRoute
|
||||||
'/admin/logins': typeof AuthAdminLoginsRoute
|
'/admin/logins': typeof AuthAdminLoginsRoute
|
||||||
'/admin/oidc': typeof AuthAdminOidcRoute
|
'/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/': typeof AuthAdminIndexRoute
|
||||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -100,10 +178,19 @@ export interface FileRoutesByTo {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/account': typeof AuthAccountRoute
|
'/account': typeof AuthAccountRoute
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
|
'/design-preview': typeof DesignPreviewIndexRoute
|
||||||
|
'/design-preview/login': typeof DesignPreviewLoginRoute
|
||||||
'/admin/apps': typeof AuthAdminAppsRoute
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
'/admin/audit': typeof AuthAdminAuditRoute
|
'/admin/audit': typeof AuthAdminAuditRoute
|
||||||
'/admin/logins': typeof AuthAdminLoginsRoute
|
'/admin/logins': typeof AuthAdminLoginsRoute
|
||||||
'/admin/oidc': typeof AuthAdminOidcRoute
|
'/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': typeof AuthAdminIndexRoute
|
||||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -111,14 +198,25 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
|
'/design-preview': typeof DesignPreviewRouteWithChildren
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/_auth/account': typeof AuthAccountRoute
|
'/_auth/account': typeof AuthAccountRoute
|
||||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/_auth/apps': typeof AuthAppsRoute
|
'/_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/apps': typeof AuthAdminAppsRoute
|
||||||
'/_auth/admin/audit': typeof AuthAdminAuditRoute
|
'/_auth/admin/audit': typeof AuthAdminAuditRoute
|
||||||
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
|
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
|
||||||
'/_auth/admin/oidc': typeof AuthAdminOidcRoute
|
'/_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/': typeof AuthAdminIndexRoute
|
||||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -126,14 +224,24 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/design-preview'
|
||||||
| '/logout'
|
| '/logout'
|
||||||
| '/account'
|
| '/account'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
|
| '/design-preview/login'
|
||||||
|
| '/design-preview/'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
| '/admin/logins'
|
| '/admin/logins'
|
||||||
| '/admin/oidc'
|
| '/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/'
|
||||||
| '/admin/users/$userId'
|
| '/admin/users/$userId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -142,24 +250,44 @@ export interface FileRouteTypes {
|
|||||||
| '/logout'
|
| '/logout'
|
||||||
| '/account'
|
| '/account'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
|
| '/design-preview'
|
||||||
|
| '/design-preview/login'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
| '/admin/logins'
|
| '/admin/logins'
|
||||||
| '/admin/oidc'
|
| '/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'
|
||||||
| '/admin/users/$userId'
|
| '/admin/users/$userId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
|
| '/design-preview'
|
||||||
| '/logout'
|
| '/logout'
|
||||||
| '/_auth/account'
|
| '/_auth/account'
|
||||||
| '/_auth/admin'
|
| '/_auth/admin'
|
||||||
| '/_auth/apps'
|
| '/_auth/apps'
|
||||||
|
| '/design-preview/_shell'
|
||||||
|
| '/design-preview/login'
|
||||||
|
| '/design-preview/'
|
||||||
| '/_auth/admin/apps'
|
| '/_auth/admin/apps'
|
||||||
| '/_auth/admin/audit'
|
| '/_auth/admin/audit'
|
||||||
| '/_auth/admin/logins'
|
| '/_auth/admin/logins'
|
||||||
| '/_auth/admin/oidc'
|
| '/_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/'
|
||||||
| '/_auth/admin/users/$userId'
|
| '/_auth/admin/users/$userId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -167,6 +295,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
|
DesignPreviewRoute: typeof DesignPreviewRouteWithChildren
|
||||||
LogoutRoute: typeof LogoutRoute
|
LogoutRoute: typeof LogoutRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +315,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthRouteImport
|
preLoaderRoute: typeof AuthRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/design-preview': {
|
||||||
|
id: '/design-preview'
|
||||||
|
path: '/design-preview'
|
||||||
|
fullPath: '/design-preview'
|
||||||
|
preLoaderRoute: typeof DesignPreviewRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/logout': {
|
'/logout': {
|
||||||
id: '/logout'
|
id: '/logout'
|
||||||
path: '/logout'
|
path: '/logout'
|
||||||
@@ -214,6 +350,27 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthAppsRouteImport
|
preLoaderRoute: typeof AuthAppsRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
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/': {
|
'/_auth/admin/': {
|
||||||
id: '/_auth/admin/'
|
id: '/_auth/admin/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -249,6 +406,55 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthAdminOidcRouteImport
|
preLoaderRoute: typeof AuthAdminOidcRouteImport
|
||||||
parentRoute: typeof AuthAdminRoute
|
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': {
|
'/_auth/admin/users/$userId': {
|
||||||
id: '/_auth/admin/users/$userId'
|
id: '/_auth/admin/users/$userId'
|
||||||
path: '/users/$userId'
|
path: '/users/$userId'
|
||||||
@@ -295,9 +501,49 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||||||
|
|
||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
|
DesignPreviewRoute: DesignPreviewRouteWithChildren,
|
||||||
LogoutRoute: LogoutRoute,
|
LogoutRoute: LogoutRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
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