diff --git a/apps/web/src/design-preview/create-sheets.tsx b/apps/web/src/design-preview/create-sheets.tsx new file mode 100644 index 0000000..b86c015 --- /dev/null +++ b/apps/web/src/design-preview/create-sheets.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react' +import type { CreateUserRequest } from '@authportal/shared' +import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' +import { Button } from '@authportal/ui/components/button' +import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/components/field' +import { Input } from '@authportal/ui/components/input' +import { Switch } from '@authportal/ui/components/switch' +import { Textarea } from '@authportal/ui/components/textarea' +import { PreviewSheet, SignalAlert } from './bits' + +/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */ +export function CreateUserPreviewSheet({ + open, + onOpenChange, + onSubmit, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (values: CreateUserRequest) => void +}) { + const [email, setEmail] = useState('') + const [name, setName] = useState('') + const [password, setPassword] = useState('') + const [admin, setAdmin] = useState(false) + const [touched, setTouched] = useState(false) + + const passwordError = password.length < 12 ? 'Минимум 12 символов' : null + const emailError = !/^\S+@\S+\.\S+$/.test(email) ? 'Укажите email' : null + const valid = !passwordError && !emailError + + function reset() { + setEmail('') + setName('') + setPassword('') + setAdmin(false) + setTouched(false) + } + + return ( + { + if (!o) reset() + onOpenChange(o) + }} + dirty={Boolean(email || name || password || admin)} + title="Создать пользователя" + description="После создания откроются права доступа" + footer={ + <> + onOpenChange(false)}> + Отмена + + { + setTouched(true) + if (!valid) return + onSubmit({ email, name: name || email.split('@')[0]!, password, is_admin: admin, apps: [], permissions: [] }) + reset() + }} + > + Создать + + > + } + > + + + Email * + + setEmail(e.target.value)} /> + {touched && emailError ? {emailError} : Станет логином} + + + Имя + setName(e.target.value)} /> + + + + Временный пароль * + + setPassword(e.target.value)} /> + {touched && passwordError ? ✕ {passwordError} : Пользователь заменит его на passkey} + + + + + Администратор + Полный доступ к порталу и ко всем приложениям + + + + Создание записывается в журнал «Изменения» с вашим именем и IP. + + + ) +} + +/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */ +export function CreateOidcPreviewSheet({ + open, + onOpenChange, + onSubmit, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (values: CreateOidcClientValues) => void +}) { + const [name, setName] = useState('') + const [uris, setUris] = useState('') + const [enabled, setEnabled] = useState(true) + const [touched, setTouched] = useState(false) + + const list = uris + .split('\n') + .map((x) => x.trim()) + .filter(Boolean) + const urisError = list.length === 0 || list.some((u) => !/^https:\/\/[^\s*]+$/.test(u)) ? 'Укажите точный https-адрес колбэка приложения.' : null + const nameError = name.trim() ? null : 'Введите название' + const valid = !urisError && !nameError + + function reset() { + setName('') + setUris('') + setEnabled(true) + setTouched(false) + } + + return ( + { + if (!o) reset() + onOpenChange(o) + }} + dirty={Boolean(name || uris)} + title="Создать OIDC-клиент" + description="Секрет будет показан один раз после создания" + footer={ + <> + onOpenChange(false)}> + Отмена + + { + setTouched(true) + if (!valid) return + onSubmit({ name: name.trim(), redirect_uris: list, scopes: ['openid', 'profile', 'email'], enabled }) + reset() + }} + > + Создать + + > + } + > + + + Название * + + setName(e.target.value)} /> + {touched && nameError ? {nameError} : null} + + + + Redirect URI * + + setUris(e.target.value)} + /> + {touched && urisError ? ( + ✕ {urisError} + ) : ( + По одному на строку · только https · точное совпадение + )} + + + + + Клиент включён + Выключенный клиент не может получить токены + + + + ) +} diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx index 90f593a..0db74ef 100644 --- a/apps/web/src/design-preview/pages/app-links-page.tsx +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -4,8 +4,6 @@ import { CloudIcon, LayoutDashboardIcon, NetworkIcon, - PlusIcon, - RotateCcwIcon, ServerIcon, type LucideIcon } from 'lucide-react' @@ -49,6 +47,9 @@ const ICONS: Record = chart: { label: 'График', Icon: ChartColumnIcon } } +/** Названия иконок, как они подписаны на доске. */ +const BOARD_ICON: Record = { cfdm: 'cloud', vps: 'server', bgp: 'globe', fw: 'shield', dns: 'globe', cdn: 'cloud', mm: 'layout' } + const MODES = [ { value: 'JWT', label: 'JWT SSO (fragment)' }, { value: 'OIDC', label: 'OIDC' }, @@ -144,7 +145,7 @@ export function AppLinksPage() { () => [ { id: 'name', title: 'Приложение', size: 260, cell: (r) => }, { id: 'status', title: 'Статус', size: 130, cell: (r) => {r.enabled ? 'В меню' : 'Скрыто'} }, - { id: 'icon', title: 'Иконка', size: 120, cell: (r) => {r.icon} }, + { id: 'icon', title: 'Иконка', size: 120, cell: (r) => {BOARD_ICON[r.id] ?? r.icon} }, { id: 'url', title: 'URL', size: 260, cell: (r) => {r.url} }, { id: 'mode', title: 'Вход', size: 90, cell: (r) => {r.mode} }, ], @@ -167,7 +168,6 @@ export function AppLinksPage() { }) } > - Добавить приложение } @@ -203,7 +203,6 @@ export function AppLinksPage() { ]} tools={ - Сбросить порядок } diff --git a/apps/web/src/design-preview/pages/audit-page.tsx b/apps/web/src/design-preview/pages/audit-page.tsx index 3ace05b..fecdf16 100644 --- a/apps/web/src/design-preview/pages/audit-page.tsx +++ b/apps/web/src/design-preview/pages/audit-page.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { Trash2Icon } from 'lucide-react' import { toast } from 'sonner' import { KpiStrip } from '../kpi-strip' import { Button } from '@authportal/ui/components/button' @@ -42,7 +41,6 @@ export function AuditPage() { sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest" actions={ setPurgeOpen(true)}> - Очистить старые записи… } diff --git a/apps/web/src/design-preview/pages/logins-page.tsx b/apps/web/src/design-preview/pages/logins-page.tsx index 130bff9..99ec923 100644 --- a/apps/web/src/design-preview/pages/logins-page.tsx +++ b/apps/web/src/design-preview/pages/logins-page.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from 'react' import { useNavigate } from '@tanstack/react-router' -import { DownloadIcon } from 'lucide-react' import { toast } from 'sonner' import { KpiStrip } from '../kpi-strip' import { Button } from '@authportal/ui/components/button' @@ -49,7 +48,6 @@ export function LoginsPage() { sub="Журнал входов и активные сессии · портал и SSO в приложения" actions={ toast.success('CSV сформирован (макет)')}> - Экспорт CSV } diff --git a/apps/web/src/design-preview/pages/oidc-page.tsx b/apps/web/src/design-preview/pages/oidc-page.tsx index c5a3679..872ff31 100644 --- a/apps/web/src/design-preview/pages/oidc-page.tsx +++ b/apps/web/src/design-preview/pages/oidc-page.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from 'react' -import { CopyIcon, PlusIcon, RefreshCwIcon } from 'lucide-react' +import { CopyIcon } from 'lucide-react' import { toast } from 'sonner' -import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' +import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' +import { CreateOidcPreviewSheet } from '../create-sheets' import { KpiStrip } from '../kpi-strip' import { Button } from '@authportal/ui/components/button' import { @@ -82,7 +83,6 @@ export function OidcPage() { sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top" actions={ setCreateOpen(true)}> - Создать клиента } @@ -131,7 +131,6 @@ export function OidcPage() { variant="outline" onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)} > - Обновить секрет… } @@ -150,7 +149,7 @@ export function OidcPage() { - + setAddOpen(true)}> - Добавить passkey } /> - {keys.length === 1 ? ( - Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. - - ) : null} + r.id} + searchText={(r) => `${r.name} ${r.note} ${r.kind}`} views={[ { label: 'Все', match: () => true }, { label: 'Давно не используются', match: (r) => r.tone === 'wn' }, diff --git a/apps/web/src/design-preview/pages/users-page.tsx b/apps/web/src/design-preview/pages/users-page.tsx index f5cddd5..725ca32 100644 --- a/apps/web/src/design-preview/pages/users-page.tsx +++ b/apps/web/src/design-preview/pages/users-page.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { AdminUser, CreateUserRequest } from '@authportal/shared' -import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet' +import { CreateUserPreviewSheet } from '../create-sheets' import { KpiStrip } from '../kpi-strip' import { Button } from '@authportal/ui/components/button' import { Tabs, TabsContent } from '@authportal/ui/components/tabs' -import { UserPlusIcon } from 'lucide-react' import { AccessSheet } from '../access-sheet' import { diffAccess, type AccessValue } from '../access-model' import { PageHead, TypedConfirm, PageTab, PageTabsList } from '../bits' @@ -98,7 +97,6 @@ export function UsersPage() { sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал" actions={ setCreateOpen(true)}> - Создать пользователя } @@ -132,7 +130,7 @@ export function UsersPage() { - + {accessFor ? ( = { ok: 'bg-success', wn: 'bg-warning', er: 'bg-destructive', - in: 'bg-info', + in: 'bg-success', mu: 'bg-muted-foreground/50', }