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
+207 -23

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, CloudIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
NetworkIcon, NetworkIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon, ServerIcon,
type LucideIcon type LucideIcon
} from 'lucide-react' } from 'lucide-react'
@@ -49,6 +47,9 @@ const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> =
chart: { label: 'График', Icon: ChartColumnIcon } 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 = [ const MODES = [
{ value: 'JWT', label: 'JWT SSO (fragment)' }, { value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' }, { 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: '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: '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: '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> }, { 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> </Button>
} }
@@ -203,7 +203,6 @@ export function AppLinksPage() {
]} ]}
tools={ tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}> <Button type="button" size="sm" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden />
Сбросить порядок Сбросить порядок
</Button> </Button>
} }
@@ -1,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { Trash2Icon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
@@ -42,7 +41,6 @@ export function AuditPage() {
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest" sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={ actions={
<Button variant="outline" onClick={() => setPurgeOpen(true)}> <Button variant="outline" onClick={() => setPurgeOpen(true)}>
<Trash2Icon aria-hidden />
Очистить старые записи… Очистить старые записи…
</Button> </Button>
} }
@@ -1,6 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { DownloadIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
@@ -49,7 +48,6 @@ export function LoginsPage() {
sub="Журнал входов и активные сессии · портал и SSO в приложения" sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={ actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}> <Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
<DownloadIcon aria-hidden />
Экспорт CSV Экспорт CSV
</Button> </Button>
} }
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CopyIcon, PlusIcon, RefreshCwIcon } from 'lucide-react' import { CopyIcon } from 'lucide-react'
import { toast } from 'sonner' 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 { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { import {
@@ -82,7 +83,6 @@ export function OidcPage() {
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top" sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<PlusIcon aria-hidden />
Создать клиента Создать клиента
</Button> </Button>
} }
@@ -131,7 +131,6 @@ export function OidcPage() {
variant="outline" variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)} onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
> >
<RefreshCwIcon aria-hidden />
Обновить секрет… Обновить секрет…
</Button> </Button>
} }
@@ -150,7 +149,7 @@ export function OidcPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateOidcClientSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} onSubmit={onCreate} /> <CreateOidcPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PreviewSheet <PreviewSheet
open={editing !== null} open={editing !== null}
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { KeyRoundIcon, PlusIcon, TrashIcon } from 'lucide-react' import { KeyRoundIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { KpiStrip } from '../kpi-strip' import { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
@@ -45,12 +45,10 @@ export function SecurityPage() {
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом" sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={ actions={
<Button onClick={() => setAddOpen(true)}> <Button onClick={() => setAddOpen(true)}>
<PlusIcon aria-hidden />
Добавить passkey Добавить passkey
</Button> </Button>
} }
/> />
{keys.length === 1 ? (
<ExplainBanner <ExplainBanner
tone="wn" tone="wn"
title="Один passkey — единственная копия." title="Один passkey — единственная копия."
@@ -58,7 +56,6 @@ export function SecurityPage() {
> >
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности. Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</ExplainBanner> </ExplainBanner>
) : null}
<KpiStrip <KpiStrip
aria-label="Показатели" aria-label="Показатели"
items={[ items={[
@@ -81,6 +78,7 @@ export function SecurityPage() {
rows={view} rows={view}
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
views={[ views={[
{ label: 'Все', match: () => true }, { label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' }, { label: 'Давно не используются', match: (r) => r.tone === 'wn' },
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared' 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 { KpiStrip } from '../kpi-strip'
import { Button } from '@authportal/ui/components/button' import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs' import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { UserPlusIcon } from 'lucide-react'
import { AccessSheet } from '../access-sheet' import { AccessSheet } from '../access-sheet'
import { diffAccess, type AccessValue } from '../access-model' import { diffAccess, type AccessValue } from '../access-model'
import { PageHead, TypedConfirm, PageTab, PageTabsList } from '../bits' import { PageHead, TypedConfirm, PageTab, PageTabsList } from '../bits'
@@ -98,7 +97,6 @@ export function UsersPage() {
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал" sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя Создать пользователя
</Button> </Button>
} }
@@ -132,7 +130,7 @@ export function UsersPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} /> <CreateUserPreviewSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? ( {accessFor ? (
<AccessSheet <AccessSheet
+1 -1
View File
@@ -19,6 +19,6 @@ export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success', ok: 'bg-success',
wn: 'bg-warning', wn: 'bg-warning',
er: 'bg-destructive', er: 'bg-destructive',
in: 'bg-info', in: 'bg-success',
mu: 'bg-muted-foreground/50', mu: 'bg-muted-foreground/50',
} }