fix(ui): завершить доступность формы пользователей

This commit is contained in:
Denozordec committed 2026-10-01 10:35:34 +07:00
1 parent 9320c11d58
commit 1e0fc4cd2d
19 files changed
+139 -60

No files matched your search

+17 -6
View File
@@ -96,8 +96,18 @@ function UsersContent() {
const mutationController = useRef<AbortController | null>(null)
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
const formReturnFocus = useRef<HTMLElement | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null)
useEffect(() => {
if (sheetOpen || !formReturnFocus.current) return
const label = formReturnFocus.current.getAttribute("aria-label")
const frame = requestAnimationFrame(() => {
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
})
return () => cancelAnimationFrame(frame)
}, [sheetOpen])
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => {
@@ -236,20 +246,20 @@ function UsersContent() {
}
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full min-w-0">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<>
{isLive && <Button type="button" size="sm" variant="outline" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить пользователей</Button>}
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Добавить пользователя
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
<div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 flex flex-col gap-5">
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
@@ -295,13 +305,14 @@ function UsersContent() {
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading && !usersKnown}
actionsDisabled={!writeReady || saving || deleting}
onEdit={(u) => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
onEdit={(u) => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
/>
</DataPageCard>}
</div>
<UserSheet
returnFocus={formReturnFocus}
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
+11 -9
View File
@@ -31,9 +31,9 @@ import {
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
<div aria-hidden="true"
className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
"size-8 rounded-full flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
!active && "opacity-50",
)}
>
@@ -74,11 +74,13 @@ function UsersDataGrid({
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
<Button type="button" size="sm" variant="ghost" aria-label={"Доступ и привязки пользователя " + u.name} aria-expanded={expanded} className="size-8 shrink-0 p-0" onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
)}
</Button>
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
@@ -137,7 +139,7 @@ function UsersDataGrid({
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CableIcon className="size-3.5" />
<CableIcon aria-hidden="true" className="size-3.5" />
{row.original.bindings.length}
</span>
),
@@ -182,14 +184,14 @@ function UsersDataGrid({
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
className="size-11 md:size-8 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)}
type="button"
disabled={actionsDisabled}
aria-label={"Редактировать пользователя " + u.name}
title="Редактировать"
>
<PencilIcon className="size-3.5" />
<PencilIcon aria-hidden="true" className="size-3.5" />
</Button>
<Button
size="sm"
@@ -201,7 +203,7 @@ function UsersDataGrid({
aria-label={"Удалить пользователя " + u.name}
title="Удалить"
>
<TrashIcon className="size-3.5" />
<TrashIcon aria-hidden="true" className="size-3.5" />
</Button>
</div>
)
@@ -228,7 +230,7 @@ function UsersDataGrid({
if (!isLoading && users.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
icon={<UsersIcon aria-hidden="true" className="size-4" />}
title="Нет пользователей"
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
className="border-0 py-12"
+63 -41
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -79,28 +79,31 @@ function pickKey(p: CatalogPick): string {
}
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
"Обзор": <LayoutDashboardIcon className="size-3" />,
"Данные": <EyeIcon className="size-3" />,
"Управление": <WrenchIcon className="size-3" />,
"Инструменты": <ShieldIcon className="size-3" />,
"Система": <ServerIcon className="size-3" />,
"Обзор": <LayoutDashboardIcon aria-hidden="true" className="size-3" />,
"Данные": <EyeIcon aria-hidden="true" className="size-3" />,
"Управление": <WrenchIcon aria-hidden="true" className="size-3" />,
"Инструменты": <ShieldIcon aria-hidden="true" className="size-3" />,
"Система": <ServerIcon aria-hidden="true" className="size-3" />,
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
function PermPills({ value, onChange, disabled, label }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean; label: string }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
<div role="group" aria-label={"Права: " + label} className={cn("flex shrink-0 rounded border border-input overflow-hidden min-h-8", disabled && "opacity-40")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v}
type="button"
disabled={disabled}
aria-label={label + ": " + o.label}
aria-pressed={value === o.v}
onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
"px-2 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
? o.v === "write" ? "bg-success/15 text-success"
: o.v === "read" ? "bg-info/15 text-info"
: "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
@@ -131,10 +134,13 @@ function toForm(user: AppUser): AppUserForm {
function isValidEmail(value: string) {
const email = value.trim()
return email.length > 0 && email.includes("@")
const input = document.createElement("input")
input.type = "email"; input.value = email
return email.length > 0 && input.checkValidity()
}
interface UserSheetProps {
returnFocus?: RefObject<HTMLElement | null>
open: boolean
user: AppUser | null
users: AppUser[]
@@ -151,8 +157,10 @@ interface UserSheetProps {
}
function UserSheet({
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose,
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose, returnFocus,
}: UserSheetProps) {
const saveErrorRef = useRef<HTMLDivElement>(null)
useEffect(() => { if (saveError) saveErrorRef.current?.scrollIntoView({ block: "nearest" }) }, [saveError])
const isCreate = user === null
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
@@ -203,23 +211,23 @@ function UserSheet({
const setSectionPerm = (section: string, level: PermLevel) => {
setForm((f) => ({
...f,
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
sections: [...f.sections.filter((s) => s.section !== section), { section, level }],
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm((f) => ({
...f,
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
servers: [...f.servers.filter((s) => s.serverId !== serverId), { serverId, level }],
}))
}
const setAllSections = (level: PermLevel) => {
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
setForm((f) => ({ ...f, sections: SECTION_GROUP_DEFS.flatMap((group) => group.items.map((section) => ({ section, level }))) }))
}
const setAllServers = (level: PermLevel) => {
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
setForm((f) => ({ ...f, servers: servers.map((server) => ({ serverId: server.id, level })) }))
}
const submit = () => {
@@ -230,6 +238,7 @@ function UserSheet({
setErrors(e)
if (Object.keys(e).length) {
setSheetStep(1)
requestAnimationFrame(() => document.getElementById(e.name ? "user-full-name" : "user-email")?.focus())
return
}
onSave({ ...form, email, login: email })
@@ -303,7 +312,7 @@ function UserSheet({
return (
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetContent finalFocus={returnFocus} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
<SheetDescription>
@@ -311,14 +320,13 @@ function UserSheet({
</SheetDescription>
</SheetHeader>
{saveError && <div role="alert" aria-label="Сохранение пользователя" className="px-6 py-3 text-sm text-destructive">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
<fieldset disabled={saving} className="flex-1 min-h-0 flex flex-col">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<fieldset disabled={saving} className="flex-1 min-h-0 min-w-0 flex flex-col">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 min-w-0 px-4 sm:px-6 py-5">
<StepperNav className="mb-3">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Профиль</StepperTitle>
<StepperTitle className="sr-only sm:not-sr-only">Профиль</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
@@ -343,21 +351,30 @@ function UserSheet({
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<p className="text-sm font-medium mb-3">{["Профиль", "Разделы", "Серверы", "Интерфейсы"][sheetStep - 1]} · {sheetStep}/4</p>
<StepperPanel className="flex-1 min-h-0 overflow-y-auto [overflow-wrap:anywhere]">
{saveError && <div role="alert" aria-label="Сохранение пользователя" ref={saveErrorRef} className="py-3 text-sm text-destructive [overflow-wrap:anywhere]">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Полное имя" required error={errors.name}>
<Input value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
<Input id="user-full-name" aria-label="Полное имя" aria-invalid={!!errors.name} aria-describedby={errors.name ? "user-name-error" : undefined} value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
{errors.name && <span id="user-name-error" className="sr-only">{errors.name}</span>}
</FormField>
<FormField label="Email" required error={errors.email}>
<Input
id="user-email"
aria-label="Email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "user-email-error" : undefined}
value={form.email}
onChange={(e) => setField("email", e.target.value)}
placeholder="i.ivanov@company.io"
type="email"
/>
{errors.email && <span id="user-email-error" className="sr-only">{errors.email}</span>}
</FormField>
<FormField label="Роль" required>
<SegmentedControl
className="w-full flex-col items-stretch sm:flex-row [&>button]:whitespace-nowrap [&>button]:min-h-9"
value={form.role}
onChange={setRole}
options={[
@@ -368,8 +385,8 @@ function UserSheet({
/>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Активна</span>
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
<span className="text-sm font-medium">Активный профиль</span>
<FormToggle ariaLabel="Активный профиль" checked={form.active} onChange={(v) => setField("active", v)} />
</div>
</StepperContent>
@@ -393,7 +410,7 @@ function UserSheet({
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
<ShieldIcon aria-hidden="true" className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
@@ -410,9 +427,9 @@ function UserSheet({
const perm = form.sections.find((s) => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between py-2.5 hover:bg-muted/20 transition-colors">
<div key={section} className="flex flex-wrap items-center justify-between gap-2 py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
<PermPills label={section} value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
@@ -445,16 +462,16 @@ function UserSheet({
const perm = form.servers.find((s) => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div key={srv.id} className="flex flex-wrap items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex flex-1 items-center gap-2.5 min-w-0">
{srv.status ? <StatusDot status={srv.status} /> : null}
{srv.country ? <Flag code={srv.country} size={14} /> : null}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
<p className="text-[11px] font-mono text-muted-foreground truncate" title={srv.host}>{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
<PermPills label={srv.name} value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
@@ -509,7 +526,7 @@ function UserSheet({
<Item
size="xs"
variant={open ? "muted" : "default"}
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
render={<button type="button" aria-expanded={open} onClick={() => setExpandedWg(open ? null : iface.name)} />}
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
>
<ItemMedia>
@@ -527,7 +544,7 @@ function UserSheet({
{legacyMine ? (
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
) : null}
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
<ChevronDownIcon aria-hidden="true" className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
</ItemActions>
</Item>
{open ? (
@@ -558,6 +575,7 @@ function UserSheet({
<button
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => togglePick(pick)}
/>
}
@@ -569,7 +587,7 @@ function UserSheet({
>
<ItemMedia>
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
<KeyRoundIcon />
<KeyRoundIcon aria-hidden="true" />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
@@ -612,6 +630,7 @@ function UserSheet({
<button
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => togglePick(pick)}
/>
}
@@ -652,12 +671,13 @@ function UserSheet({
</Frame>
<Input
className="h-8 text-xs"
aria-label="Комментарий привязки"
placeholder="Комментарий (необязательно)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
<PlusIcon aria-hidden="true" className="size-3.5" />Привязать
</Button>
</div>
@@ -678,9 +698,11 @@ function UserSheet({
size="sm"
variant="ghost"
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
type="button"
aria-label={"Удалить привязку " + bindingTitle(b) + " · " + b.serverName}
onClick={() => removeBinding(b.id)}
>
<TrashIcon className="size-3.5" />
<TrashIcon aria-hidden="true" className="size-3.5" />
</Button>
</div>
))}
@@ -689,7 +711,7 @@ function UserSheet({
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button type="button" variant="outline" disabled={saving} />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
+10 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.7; state каталог подключён; UX/приёмка пользователей далее |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.8; пользователи приняты локально, далее контейнеры |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
- Sheet занимает ширину телефона; min-w-0 fieldset удерживает шаги в 320 px, выбор роли переносится вертикально без разрыва слов. Ошибка сохранения внутри области прокрутки, footer остаётся доступен; обязательные поля имеют aria-invalid/describedby и получают фокус.
- Закрытие формы возвращает фокус к актуальной кнопке строки после её замены Data Grid. Найден и исправлен исходный no-op прав: отсутствующие section/server разрешения добавляются при явном выборе; «выбрать всё» охватывает показанные разделы/серверы.
- 48 уникальных data/operations/catalog/layout E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. HTTP payload проверен для отсутствующих разрешений. 12 снимков ПК/390/320 сохранены; просмотрены обзор/профиль/каталог/ошибка, горизонтальная прокрутка ограничена таблицей.
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
## Блок 11А.7 — frontend state каталог (2026-10-01)
- UserSheet использует /interface-catalog-state, строго проверяет новый контракт и точный serverId. Legacy reader экспортирован для совместимости, новый экран к неоднозначному envelope не возвращается.
+10
View File
@@ -96,3 +96,13 @@
- Частичные failures интерфейсов/WG/IPsec видны сразу с повтором, включая пустой IPsec failure. IPsec-vpn подписан как каталог клиентов, а не физический интерфейс. Демо и live состояния сохраняют прежнюю композицию Frame/Stepper.
- 44 различных data/operations/catalog E2E + 8 context E2E, TypeScript, профильный lint и production frontend прошли. Фикстуры перенесены на новый state envelope; отдельные negative тесты не дополняют отсутствующие метаданные автоматически.
- Далее 11А.8: семантические токены таблицы/формы, доступные имена, права/клавиатура, ПК/390/320 и снимки. После приёмки пользователей — контейнеры. Реальная БД/RouterOS/Portal остаётся внешней приёмкой.
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
- Sheet занимает ширину телефона; min-w-0 fieldset удерживает шаги в 320 px, выбор роли переносится вертикально без разрыва слов. Ошибка сохранения внутри области прокрутки, footer остаётся доступен; обязательные поля имеют aria-invalid/describedby и получают фокус.
- Закрытие формы возвращает фокус к актуальной кнопке строки после её замены Data Grid. Найден и исправлен исходный no-op прав: отсутствующие section/server разрешения добавляются при явном выборе; «выбрать всё» охватывает показанные разделы/серверы.
- 48 уникальных data/operations/catalog/layout E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. HTTP payload проверен для отсутствующих разрешений. 12 снимков ПК/390/320 сохранены; просмотрены обзор/профиль/каталог/ошибка, горизонтальная прокрутка ограничена таблицей.
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+3 -3
View File
@@ -92,8 +92,8 @@ export const ROLE_LABEL: Record<Role, string> = {
}
export const ROLE_COLOR: Record<Role, string> = {
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
admin: "bg-primary/10 text-primary border border-primary/20",
operator: "bg-info/10 text-info border border-info/20",
viewer: "bg-muted text-muted-foreground border border-border",
}
@@ -105,7 +105,7 @@ export const PERM_OPTS: { v: PermLevel; label: string }[] = [
export const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
read: "bg-info/10 text-info",
write: "bg-success/10 text-success",
}
+25
View File
@@ -0,0 +1,25 @@
import {test,expect} from "@playwright/test"
import {user,server,catalogResponse} from "./fixtures/users"
for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Пользователи: клавиатура и адаптивность "+name,async({page})=>{
await page.setViewportSize({width,height:900});const longUser={...user,name:"Очень длинное имя пользователя для проверки таблицы и формы",email:"long-user-name@example.test",login:"long-user-name@example.test"}
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return catalogResponse(r,{json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[longUser]}:p==="/api/servers"?[{...server,name:"Очень длинное имя устройства для проверки права доступа",host:"router-with-long-hostname.example.test"}]:{interfaces:[]}})})
let saved: {sections: {section:string;level:string}[];servers:{serverId:string;level:string}[]} | undefined
await page.route("**/api/users/u-test",r=>{saved=r.request().postDataJSON();return r.fulfill({status:503,json:{error:"Профиль не сохранён. Повторите позже."}})})
await page.goto("/users");await expect(page.getByRole("button",{name:"Добавить пользователя",exact:true})).toBeEnabled();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
const expand=page.getByRole("button",{name:"Доступ и привязки пользователя "+longUser.name});await expand.focus();await page.keyboard.press("Enter");await expect(expand).toHaveAttribute("aria-expanded","true");await expect(page.getByText("Нет привязанных интерфейсов",{exact:true})).toBeVisible()
await page.screenshot({path:"docs/ux-ui/screens/users-overview-"+name+".png",fullPage:true})
const edit=page.getByRole("button",{name:"Редактировать пользователя "+longUser.name});await edit.focus();await page.keyboard.press("Enter");const dialog=page.getByRole("dialog");await expect(page.getByRole("textbox",{name:"Полное имя",exact:true})).toHaveValue(longUser.name);await expect(page.getByRole("textbox",{name:"Email",exact:true})).toHaveValue(longUser.email)
const bounds=await dialog.boundingBox();expect(bounds&&bounds.x>=0&&bounds.x+bounds.width<=width+1).toBe(true);expect(width>=640||bounds&&bounds.width>=width-2).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/users-profile-"+name+".png",fullPage:true})
const sections=dialog.getByRole("tab",{name:/Разделы/});await sections.focus();await page.keyboard.press("Enter");await expect(sections).toHaveAttribute("aria-selected","true")
const permission=dialog.getByRole("button",{name:"Дашборд: Управление",exact:true});await permission.focus();await page.keyboard.press("Space");await expect(permission).toHaveAttribute("aria-pressed","true")
await dialog.getByRole("tab",{name:/Серверы/}).click();await expect(dialog.getByRole("group",{name:"Права: Очень длинное имя устройства для проверки права доступа"})).toBeVisible();await dialog.getByRole("button",{name:"Очень длинное имя устройства для проверки права доступа: Управление",exact:true}).click();await dialog.getByRole("tab",{name:/Интерфейсы/}).click();await expect(dialog.getByText("Нет интерфейсов в каталоге",{exact:true})).toBeVisible()
await page.screenshot({path:"docs/ux-ui/screens/users-catalog-"+name+".png",fullPage:true})
await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toContainText("Профиль не сохранён");expect(saved?.sections).toContainEqual({section:"Дашборд",level:"write"});expect(saved?.servers).toContainEqual({serverId:"1",level:"write"});const footer=dialog.getByRole("button",{name:"Сохранить",exact:true});const box=await footer.boundingBox();expect(box&&box.y>=0&&box.y+box.height<=900).toBe(true);expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/users-error-"+name+".png",fullPage:true})
await dialog.getByRole("button",{name:"Отмена",exact:true}).click();await expect(dialog).toHaveCount(0);await expect(edit).toBeFocused()
})
test("Пользователи: ошибки обязательных полей и фокус",async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>catalogResponse(r,{json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/users"?{users:[user]}:new URL(r.request().url()).pathname==="/api/servers"?[server]:{interfaces:[]}}))
await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();const dialog=page.getByRole("dialog");await dialog.getByRole("tab",{name:/Интерфейсы/}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();const name=dialog.getByRole("textbox",{name:"Полное имя",exact:true});await expect(name).toBeFocused();await expect(name).toHaveAttribute("aria-invalid","true");await name.fill("Тест");await dialog.getByRole("textbox",{name:"Email",exact:true}).fill("bad@");await dialog.getByRole("tab",{name:/Интерфейсы/}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();const email=dialog.getByRole("textbox",{name:"Email",exact:true});await expect(email).toBeFocused();await expect(email).toHaveAttribute("aria-invalid","true")
})