"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 { listInterfaceCatalog } 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, NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon, } from "lucide-react" const IFACE_TILE: Record = { ether: { icon: CableIcon, className: "text-muted-foreground" }, gre: { icon: NetworkIcon, className: "text-info" }, wg: { icon: ShieldIcon, className: "text-success" }, 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 = { "Обзор": , "Данные": , "Управление": , "Инструменты": , "Система": , } function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) { return (
{PERM_OPTS.map((o, i) => ( ))}
) } 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 onSave: (form: AppUserForm) => void onClose: () => void } function UserSheet({ open, user, users, servers, isLive, backendUrl, saving, onSave, onClose, }: UserSheetProps) { const isCreate = user === null const [sheetStep, setSheetStep] = useState(1) const [form, setForm] = useState(() => (user ? toForm(user) : emptyForm(servers))) const [errors, setErrors] = useState>>({}) const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "") const [catalog, setCatalog] = useState([]) const [selectedPicks, setSelectedPicks] = useState([]) const [expandedWg, setExpandedWg] = useState(null) const [newComment, setNewComment] = useState("") useEffect(() => { if (!open) return setForm(user ? toForm(user) : emptyForm(servers)) setSheetStep(1) setErrors({}) setCatalogServerId(servers[0]?.id ?? "") setSelectedPicks([]) setExpandedWg(null) setNewComment("") }, [open, user, servers]) useEffect(() => { if (!open || !catalogServerId) { setCatalog([]) return } if (!isLive) { 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 }] setCatalog(catalogForServer(catalogServerId, withDraft)) return } let cancelled = false void listInterfaceCatalog(backendUrl, catalogServerId) .then((ifaces) => { if (!cancelled) setCatalog(ifaces) }) .catch(() => { if (!cancelled) setCatalog([]) }) return () => { cancelled = true } }, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings]) const setField = (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> = {} 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 catalogSrv = servers.find((s) => s.id === catalogServerId) const addSelectedBindings = () => { if (!catalogSrv || selectedPicks.length === 0) return const existing = new Set(form.bindings.map(bindingDiffKey)) const next: InterfaceBinding[] = [...form.bindings] for (const pick of selectedPicks) { 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 ( { if (!v) onClose() }}> {isCreate ? "Новый пользователь" : "Редактировать пользователя"} {isCreate ? "Профиль, права и привязка интерфейсов" : "Права доступа и привязка интерфейсов"} 1 Профиль 2 Разделы 3 Серверы 4 Интерфейсы setField("name", e.target.value)} placeholder="Иван Иванов" /> setField("email", e.target.value)} placeholder="i.ivanov@company.io" type="email" />
Активна setField("active", v)} />
Выбрать всё: {PERM_OPTS.map((o) => ( ))} {isAdmin && ( Администратор имеет полный доступ )}
{SECTION_GROUP_DEFS.map((group) => (
{SECTION_GROUP_ICONS[group.group]} {group.group}
{group.items.map((section) => { const perm = form.sections.find((s) => s.section === section) const level = perm?.level ?? "none" return (
{section} setSectionPerm(section, v)} disabled={isAdmin} />
) })}
))}
Выбрать всё: {PERM_OPTS.map((o) => ( ))}
{servers.map((srv) => { const perm = form.servers.find((s) => s.serverId === srv.id) const level = perm?.level ?? "none" return (
{srv.status ? : null} {srv.country ? : null}

{srv.name}

{srv.host} · {srv.site}

setServerPerm(srv.id, v)} disabled={isAdmin} />
) })}

Ethernet и GRE — целиком. WireGuard — только пир (public-key).

{catalog.length === 0 && (

Нет интерфейсов в каталоге

)}
{catalog.map((iface) => { const tile = IFACE_TILE[iface.type] const Icon = tile.icon if (iface.type === "wg") { const open = expandedWg === iface.name const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id) const legacyMine = alreadyBoundHere.has(`${iface.name}\0`) return (
setExpandedWg(open ? null : iface.name)} />} className="h-11 min-h-11 flex-nowrap rounded-md py-0" > {iface.name} WireGuard {legacyMine ? ( весь интерфейс ) : null} {open ? (
{iface.peersError ? (

Не удалось загрузить пиры

) : null} {(iface.peers ?? []).length === 0 && !iface.peersError ? (

Нет пиров на интерфейсе

) : null} {(iface.peers ?? []).map((peer) => { const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: peer.publicKey, peerName: peer.name, type: "wg", } const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id) const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`) const disabled = taken || mine || legacyTaken const selected = selectedPickKeys.has(pickKey(pick)) return ( 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", )} > {peer.name || peer.publicKey} {peer.latestHandshake ? ( handshake ) : null} {taken ? ( {peer.boundUserLogin} ) : mine ? ( уже привязан ) : selected ? ( Выбран ) : null} ) })}
) : null}
) } 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 = taken || mine const selected = selectedPickKeys.has(pickKey(pick)) return ( 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", )} > {iface.running ? : } {iface.name} {IFACE_TYPE_LABEL[iface.type]} {taken ? ( {iface.boundUserLogin} ) : mine ? ( уже привязан ) : selected ? ( Выбран ) : null} ) })}
setNewComment(e.target.value)} />
{form.bindings.length === 0 && (

Нет привязанных интерфейсов

)} {form.bindings.map((b) => (
{bindingTitle(b)} {IFACE_TYPE_LABEL[b.interfaceType]} {b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? ( весь интерфейс ) : null} {b.serverName}
))}
}>Отмена {sheetStep > 1 && ( )} {sheetStep < 4 ? ( ) : ( )}
) } export { UserSheet, type UserSheetProps }