Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
690 lines
30 KiB
TypeScript
690 lines
30 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 { 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<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" },
|
|
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
|
|
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<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
|
|
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
|
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
|
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
|
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
|
|
const [expandedWg, setExpandedWg] = useState<string | null>(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 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 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 (
|
|
<Sheet open={open} onOpenChange={(v) => { if (!v) 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>
|
|
|
|
<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).
|
|
</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"
|
|
value={catalogServerId}
|
|
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
|
|
>
|
|
{servers.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
|
))}
|
|
</select>
|
|
<Frame dense spacing="sm">
|
|
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
|
|
{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") {
|
|
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.wg} size="sm">WireGuard</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 className="px-2 py-1.5 text-[11px] text-muted-foreground">Не удалось загрузить пиры</p>
|
|
) : null}
|
|
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
|
|
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
|
|
) : 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 (
|
|
<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="success-light" size="xs">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 = 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={selectedPicks.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 variant="outline" />}>Отмена</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} onClick={submit}>
|
|
{isCreate ? "Создать" : "Сохранить"}
|
|
</Button>
|
|
)}
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
export { UserSheet, type UserSheetProps }
|