Files
MikrotikManager/components/users/user-sheet.tsx
T

704 lines
35 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Frame, FramePanel } from "@/components/reui/frame"
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { can } from "@/lib/auth"
import { listInterfaceCatalog, userApiError } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
bindingDiffKey,
bindingTitle,
catalogForServer,
defaultSections,
defaultServers,
IFACE_TYPE_LABEL,
PERM_COLOR,
PERM_OPTS,
ROLE_LABEL,
SECTION_GROUP_DEFS,
type AppUser,
type AppUserForm,
type CatalogIface,
type InterfaceBinding,
type InterfaceType,
type PermLevel,
type Role,
type UserServerOption,
} from "@/lib/users"
import {
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
LockIcon, NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
} from "lucide-react"
const IFACE_TILE: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
ipsec: { icon: LockIcon, className: "text-info" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
type CatalogPick = {
interfaceName: string
peerPublicKey: string
peerName?: string
type: InterfaceType
}
function pickKey(p: CatalogPick): string {
return `${p.interfaceName}\0${p.peerPublicKey}`
}
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" />,
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
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"
: "text-muted-foreground hover:bg-muted",
)}
>
{o.label}
</button>
))}
</div>
)
}
function emptyForm(serverList: UserServerOption[]): AppUserForm {
return {
name: "", login: "", email: "", role: "viewer", active: true,
sections: defaultSections("viewer"),
servers: defaultServers("viewer", serverList),
bindings: [],
}
}
function toForm(user: AppUser): AppUserForm {
return {
name: user.name, login: user.login, email: user.email, role: user.role, active: user.active,
sections: user.sections.map((s) => ({ ...s })),
servers: user.servers.map((s) => ({ ...s })),
bindings: user.bindings.map((b) => ({ ...b })),
}
}
function isValidEmail(value: string) {
const email = value.trim()
return email.length > 0 && email.includes("@")
}
interface UserSheetProps {
open: boolean
user: AppUser | null
users: AppUser[]
servers: UserServerOption[]
isLive: boolean
backendUrl: string
saving?: boolean
saveError?: string | null
saveBlocked?: boolean
saveResume?: boolean
onCheckCatalogs?: () => void
onSave: (form: AppUserForm) => void
onClose: () => void
}
function UserSheet({
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose,
}: UserSheetProps) {
const isCreate = user === null
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
const [expandedWg, setExpandedWg] = useState<string | null>(null)
const [newComment, setNewComment] = useState("")
const catalogSrv = servers.find(s => s.id === catalogServerId)
const catalogKey = JSON.stringify([catalogServerId, catalogSrv?.host])
const [catalogState, setCatalogState] = useState<{ key: string; serverId: string; rows: CatalogIface[]; loading: boolean; error?: string; readAt?: number } | null>(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const demoCatalog = useMemo(() => {
if (isLive || !catalogSrv) return []
const occupancy = users.map(u => u.id === user?.id ? { ...u, bindings: form.bindings } : u)
const withDraft = user ? occupancy : [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
return catalogForServer(catalogServerId, withDraft)
}, [isLive, catalogSrv, catalogServerId, users, user, form.bindings])
const currentCatalog = catalogState?.key === catalogKey ? catalogState : null
const catalog = isLive ? currentCatalog?.rows ?? [] : demoCatalog
const catalogReady = !!catalogSrv && (!isLive || !!currentCatalog?.readAt && !currentCatalog.loading && !currentCatalog.error)
useEffect(() => {
if (!open || !isLive || !catalogSrv || !can("mm:users:read")) return
let cancelled = false
const controller = new AbortController()
queueMicrotask(() => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: true })) })
void listInterfaceCatalog(backendUrl, catalogServerId, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
.then(rows => { if (!cancelled) setCatalogState({ key: catalogKey, serverId: catalogServerId, rows, loading: false, readAt: Date.now() }) })
.catch(error => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: false, error: userApiError(error) })) })
return () => { cancelled = true; controller.abort() }
}, [open, isLive, catalogSrv, catalogServerId, catalogKey, backendUrl, catalogRevision])
const setField = <K extends keyof AppUserForm>(k: K, v: AppUserForm[K]) => {
setForm((f) => ({ ...f, [k]: v }))
if (errors[k]) setErrors((e) => ({ ...e, [k]: undefined }))
}
const setRole = (role: Role) => {
setForm((f) => ({
...f,
role,
sections: defaultSections(role),
servers: servers.map((s) => ({ serverId: s.id, level: role === "admin" ? "write" : "read" })),
}))
}
const setSectionPerm = (section: string, level: PermLevel) => {
setForm((f) => ({
...f,
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm((f) => ({
...f,
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
}))
}
const setAllSections = (level: PermLevel) => {
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
}
const setAllServers = (level: PermLevel) => {
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
}
const submit = () => {
const email = form.email.trim()
const e: Partial<Record<keyof AppUserForm, string>> = {}
if (!form.name.trim()) e.name = "Обязательное поле"
if (!isValidEmail(email)) e.email = email ? "Укажите корректный email" : "Обязательное поле"
setErrors(e)
if (Object.keys(e).length) {
setSheetStep(1)
return
}
onSave({ ...form, email, login: email })
}
const isAdmin = form.role === "admin"
const validSelectedPicks = catalogReady ? selectedPicks.filter(pick => {
const iface = catalog.find(item => item.name === pick.interfaceName && item.type === pick.type)
if (!iface || iface.peersError || iface.boundUserId && iface.boundUserId !== user?.id) return false
if (iface.type === "wg" || iface.type === "ipsec") {
const peer = iface.peers?.find(item => item.publicKey === pick.peerPublicKey)
return !!peer && (!peer.boundUserId || peer.boundUserId === user?.id)
}
return !pick.peerPublicKey
}) : []
const addSelectedBindings = () => {
if (!catalogReady || !catalogSrv || validSelectedPicks.length === 0 || saving) return
const existing = new Set(form.bindings.map(bindingDiffKey))
const next: InterfaceBinding[] = [...form.bindings]
for (const pick of validSelectedPicks) {
const key = bindingDiffKey({
serverId: catalogSrv.id,
interfaceName: pick.interfaceName,
peerPublicKey: pick.peerPublicKey,
})
if (existing.has(key)) continue
next.push({
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
userId: user?.id ?? "",
serverId: catalogSrv.id,
serverName: catalogSrv.name,
serverSite: catalogSrv.site,
serverCountry: catalogSrv.country,
interfaceName: pick.interfaceName,
interfaceType: pick.type,
peerPublicKey: pick.peerPublicKey || undefined,
peerName: pick.peerName,
comment: newComment.trim(),
})
}
setForm((f) => ({ ...f, bindings: next }))
setSelectedPicks([])
setNewComment("")
}
const removeBinding = (id: string) => {
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
}
const togglePick = (pick: CatalogPick) => {
setSelectedPicks((prev) => {
const key = pickKey(pick)
return prev.some((p) => pickKey(p) === key)
? prev.filter((p) => pickKey(p) !== key)
: [...prev, pick]
})
}
const alreadyBoundHere = useMemo(
() => new Set(
form.bindings
.filter((b) => b.serverId === catalogServerId)
.map((b) => `${b.interfaceName}\0${b.peerPublicKey ?? ""}`),
),
[form.bindings, catalogServerId],
)
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
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">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
<SheetDescription>
{isCreate ? "Профиль, права и привязка интерфейсов" : "Права доступа и привязка интерфейсов"}
</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">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Профиль</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Разделы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Интерфейсы</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<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="Иван Иванов" />
</FormField>
<FormField label="Email" required error={errors.email}>
<Input
value={form.email}
onChange={(e) => setField("email", e.target.value)}
placeholder="[email protected]"
type="email"
/>
</FormField>
<FormField label="Роль" required>
<SegmentedControl
value={form.role}
onChange={setRole}
options={[
{ value: "viewer", label: ROLE_LABEL.viewer },
{ value: "operator", label: ROLE_LABEL.operator },
{ value: "admin", label: ROLE_LABEL.admin },
]}
/>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Активна</span>
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
</div>
</StepperContent>
<StepperContent value={2} className="flex flex-col">
<div className="flex items-center gap-2 px-0 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUP_DEFS.map((group) => (
<div key={group.group}>
<div className="flex items-center gap-2 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{SECTION_GROUP_ICONS[group.group]}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
<div className="divide-y divide-border/60">
{group.items.map((section) => {
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">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</StepperContent>
<StepperContent value={3} className="flex flex-col">
<div className="flex items-center gap-2 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
</div>
<div className="divide-y divide-border/60">
{servers.map((srv) => {
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">
{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>
</div>
</div>
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</StepperContent>
<StepperContent value={4} className="flex flex-col">
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
Ethernet и GRE — целиком. WireGuard — пир (public-key), IPsec — клиент (CN).
</p>
<div className="py-3 flex flex-col gap-3 border-b">
<label className="text-[10px] text-muted-foreground">Сервер</label>
<select
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
aria-label="Сервер каталога интерфейсов"
value={catalogSrv ? catalogServerId : ""}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
>
{!catalogSrv && <option value="" disabled>Выберите сервер</option>}
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</select>
{!catalogSrv && <p role="status">Выберите сервер каталога интерфейсов.</p>}
{isLive && currentCatalog?.loading && <p role="status">Чтение каталога интерфейсов…</p>}
{isLive && currentCatalog?.readAt && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(currentCatalog.readAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && currentCatalog?.error && <div role="alert" aria-label="Каталог интерфейсов"><p>{currentCatalog.error} Прежние данные доступны для просмотра; выбор приостановлен.</p><Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></div>}
<Frame dense spacing="sm" className="h-64 min-h-0 flex flex-col">
<FramePanel className="flex-1 min-h-0 overflow-y-auto p-1.5">
{catalogReady && catalog.length === 0 && (
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
)}
<div className="flex flex-col gap-0.5">
{catalog.map((iface) => {
const tile = IFACE_TILE[iface.type]
const Icon = tile.icon
if (iface.type === "wg" || iface.type === "ipsec") {
const open = expandedWg === iface.name
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
return (
<div key={iface.name} className="flex flex-col gap-0.5">
<Item
size="xs"
variant={open ? "muted" : "default"}
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
>
<ItemMedia>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
<Icon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">{IFACE_TYPE_LABEL[iface.type]}</Badge>
</ItemTitle>
</ItemContent>
<ItemActions>
{legacyMine ? (
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
) : null}
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
</ItemActions>
</Item>
{open ? (
<div className="ml-4 flex flex-col gap-0.5 border-l pl-2">
{iface.peersError ? (
<p role="alert" className="px-2 py-1.5 text-xs text-destructive">Не удалось загрузить {iface.type === "ipsec" ? "клиентов IPsec" : "пиры WireGuard"}: {iface.peersError}<Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></p>
) : null}
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{iface.type === "ipsec" ? "Нет клиентов IPsec" : "Нет пиров на интерфейсе"}</p>
) : null}
{(iface.peers ?? []).map((peer) => {
const pick: CatalogPick = {
interfaceName: iface.name,
peerPublicKey: peer.publicKey,
peerName: peer.name,
type: iface.type,
}
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
const disabled = !catalogReady || !!iface.peersError || taken || mine || legacyTaken
const selected = selectedPickKeys.has(pickKey(pick))
return (
<Item
key={peer.publicKey}
size="xs"
variant={selected ? "muted" : "default"}
render={
<button
type="button"
disabled={disabled}
onClick={() => togglePick(pick)}
/>
}
className={cn(
"h-11 min-h-11 flex-nowrap rounded-md py-0",
selected && "ring-1 ring-border",
disabled && "cursor-not-allowed opacity-50",
)}
>
<ItemMedia>
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
<KeyRoundIcon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{peer.name || peer.publicKey}</span>
{peer.latestHandshake ? (
<Badge variant="outline" size="xs" title={peer.latestHandshake}>{iface.type === "ipsec" ? "время активности" : "время handshake"}</Badge>
) : null}
</ItemTitle>
</ItemContent>
<ItemActions>
{taken ? (
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{peer.boundUserLogin}</span>
) : mine ? (
<span className="text-[10px] text-muted-foreground">уже привязан</span>
) : selected ? (
<Badge variant="secondary" size="xs">Выбран</Badge>
) : null}
</ItemActions>
</Item>
)
})}
</div>
) : null}
</div>
)
}
const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type }
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(`${iface.name}\0`)
const disabled = !catalogReady || taken || mine
const selected = selectedPickKeys.has(pickKey(pick))
return (
<Item
key={iface.name}
size="xs"
variant={selected ? "muted" : "default"}
render={
<button
type="button"
disabled={disabled}
onClick={() => togglePick(pick)}
/>
}
className={cn(
"h-11 min-h-11 flex-nowrap rounded-md py-0",
selected && "ring-1 ring-border",
disabled && "cursor-not-allowed opacity-50",
)}
>
<ItemMedia>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
<Icon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
{iface.running ? <StatusDot status="online" /> : <StatusDot status="offline" />}
<span className="min-w-0 truncate">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
{IFACE_TYPE_LABEL[iface.type]}
</Badge>
</ItemTitle>
</ItemContent>
<ItemActions>
{taken ? (
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{iface.boundUserLogin}</span>
) : mine ? (
<span className="text-[10px] text-muted-foreground">уже привязан</span>
) : selected ? (
<Badge variant="secondary" size="xs">Выбран</Badge>
) : null}
</ItemActions>
</Item>
)
})}
</div>
</FramePanel>
</Frame>
<Input
className="h-8 text-xs"
placeholder="Комментарий (необязательно)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
</Button>
</div>
<div className="flex flex-col gap-1.5 py-3">
{form.bindings.length === 0 && (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
)}
{form.bindings.map((b) => (
<div key={b.id} className="flex items-center gap-2 py-1">
<Flag code={b.serverCountry} size={12} />
<span className="font-mono text-xs truncate">{bindingTitle(b)}</span>
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
) : null}
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
onClick={() => removeBinding(b.id)}
>
<TrashIcon className="size-3.5" />
</Button>
</div>
))}
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button type="button" variant="outline" disabled={saving} />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" disabled={saving || saveBlocked} aria-busy={saving} onClick={submit}>
{saveResume ? "Продолжить сохранение" : isCreate ? "Создать" : "Сохранить"}
</Button>
)}
</SheetFooter>
</fieldset>
</SheetContent>
</Sheet>
)
}
export { UserSheet, type UserSheetProps }