Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade - клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers - скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase - история изменений (config_revisions, секция ipsec) и restore только managed-объектов - привязка IPsec-клиентов к пользователям приложения по Common Name - страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
691 lines
30 KiB
TypeScript
691 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,
|
|
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
|
|
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="i.ivanov@company.io"
|
|
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 }
|