fix(design-preview): окна создания, подписи и кнопки как на досках Auth Portal

Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности
всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 03:44:19 +07:00
1 parent cd6516edd6
commit 1b89135186
8 files changed
+209 -25

No files matched your search

@@ -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 (
<PreviewSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(email || name || password || admin)}
title="Создать пользователя"
description="После создания откроются права доступа"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ email, name: name || email.split('@')[0]!, password, is_admin: admin, apps: [], permissions: [] })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(emailError)}>
<FieldLabel htmlFor="cu-email">
Email <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-email" type="email" value={email} placeholder="[email protected]" aria-invalid={touched && Boolean(emailError)} onChange={(e) => setEmail(e.target.value)} />
{touched && emailError ? <FieldError>{emailError}</FieldError> : <FieldDescription>Станет логином</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="cu-name">Имя</FieldLabel>
<Input id="cu-name" value={name} placeholder="Новый пользователь" onChange={(e) => setName(e.target.value)} />
</Field>
<Field data-invalid={touched && Boolean(passwordError)}>
<FieldLabel htmlFor="cu-pw">
Временный пароль <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-pw" type="password" value={password} aria-invalid={touched && Boolean(passwordError)} onChange={(e) => setPassword(e.target.value)} />
{touched && passwordError ? <FieldError>✕ {passwordError}</FieldError> : <FieldDescription>Пользователь заменит его на passkey</FieldDescription>}
</Field>
<Field orientation="horizontal">
<Switch id="cu-admin" checked={admin} onCheckedChange={setAdmin} />
<div className="flex flex-col">
<FieldLabel htmlFor="cu-admin">Администратор</FieldLabel>
<FieldDescription>Полный доступ к порталу и ко всем приложениям</FieldDescription>
</div>
</Field>
<SignalAlert tone="wn" title="Журнал">
Создание записывается в журнал «Изменения» с вашим именем и IP.
</SignalAlert>
</PreviewSheet>
)
}
/** Окно «Создать 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 (
<PreviewSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(name || uris)}
title="Создать OIDC-клиент"
description="Секрет будет показан один раз после создания"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ name: name.trim(), redirect_uris: list, scopes: ['openid', 'profile', 'email'], enabled })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(nameError)}>
<FieldLabel htmlFor="co-name">
Название <span aria-hidden>*</span>
</FieldLabel>
<Input id="co-name" value={name} placeholder="Technitium DNS" aria-invalid={touched && Boolean(nameError)} onChange={(e) => setName(e.target.value)} />
{touched && nameError ? <FieldError>{nameError}</FieldError> : null}
</Field>
<Field data-invalid={touched && Boolean(urisError)}>
<FieldLabel htmlFor="co-uri">
Redirect URI <span aria-hidden>*</span>
</FieldLabel>
<Textarea
id="co-uri"
rows={4}
value={uris}
placeholder="https://dns.example.com/sso/callback"
aria-invalid={touched && Boolean(urisError)}
className="font-mono"
onChange={(e) => setUris(e.target.value)}
/>
{touched && urisError ? (
<FieldError>✕ {urisError}</FieldError>
) : (
<FieldDescription>По одному на строку · только https · точное совпадение</FieldDescription>
)}
</Field>
<Field orientation="horizontal">
<Switch id="co-on" checked={enabled} onCheckedChange={setEnabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="co-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</PreviewSheet>
)
}
@@ -4,8 +4,6 @@ import {
CloudIcon,
LayoutDashboardIcon,
NetworkIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon,
type LucideIcon
} from 'lucide-react'
@@ -49,6 +47,9 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
chart: { label: 'График', Icon: ChartColumnIcon }
}
/** Названия иконок, как они подписаны на доске. */
const BOARD_ICON: Record<string, string> = { 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) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
{ id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
{ id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.icon}</span> },
{ id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{BOARD_ICON[r.id] ?? r.icon}</span> },
{ id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
{ id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
],
@@ -167,7 +168,6 @@ export function AppLinksPage() {
})
}
>
<PlusIcon aria-hidden />
Добавить приложение
</Button>
}
@@ -203,7 +203,6 @@ export function AppLinksPage() {
]}
tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden />
Сбросить порядок
</Button>
}
@@ -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={
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи…
</Button>
}
@@ -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={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV
</Button>
}
@@ -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={
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента
</Button>
}
@@ -131,7 +131,6 @@ export function OidcPage() {
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
<RefreshCwIcon aria-hidden />
Обновить секрет…
</Button>
}
@@ -150,7 +149,7 @@ export function OidcPage() {
</TabsContent>
</Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} />
<CreateOidcPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet
open={editing !== null}
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon } from 'lucide-react'
import { KeyRoundIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button'
@@ -45,20 +45,17 @@ export function SecurityPage() {
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={
<Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey
</Button>
}
/>
{keys.length === 1 ? (
<ExplainBanner
<ExplainBanner
tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</ExplainBanner>
) : null}
</ExplainBanner>
<KpiStrip
aria-label="Показатели"
items={[
@@ -81,6 +78,7 @@ export function SecurityPage() {
rows={view}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
views={[
{ label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' },
@@ -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={
<Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя
</Button>
}
@@ -132,7 +130,7 @@ export function UsersPage() {
</TabsContent>
</Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
<CreateUserPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? (
<AccessSheet
+1 -1
View File
@@ -19,6 +19,6 @@ export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-info',
in: 'bg-success',
mu: 'bg-muted-foreground/50',
}