fix(design-preview): окна создания, подписи и кнопки как на досках Auth Portal
Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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="new.user@shnt.top" 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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
Reference in new issue
Block a user