fix(ui): завершить доступность формы пользователей
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="[email protected]"
|
||||
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>
|
||||
|
||||
@@ -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 не возвращается.
|
||||
|
||||
@@ -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 ещё не приняты.
|
||||
|
||||
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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:"[email protected]",login:"[email protected]"}
|
||||
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")
|
||||
})
|
||||