fix(ui): завершить доступность формы пользователей
This commit is contained in:
1 parent
9320c11d58
commit
1e0fc4cd2d
19 files changed
+139
-60
No files matched your search
@@ -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="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>
|
||||
|
||||
Reference in new issue
Block a user