241 lines
9.6 KiB
TypeScript
241 lines
9.6 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Switch } from "@/components/ui/switch"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||
SheetDescription, SheetFooter, SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||
|
||
export type WgCreateFormState = {
|
||
serverId: string
|
||
name: string
|
||
listenPort: string
|
||
mtu: string
|
||
comment: string
|
||
address: string
|
||
enabled: boolean
|
||
showAdvanced: boolean
|
||
peerEnabled: boolean
|
||
peerPublicKey: string
|
||
peerAllowedIps: string
|
||
peerEndpoint: string
|
||
peerKeepalive: string
|
||
peerComment: string
|
||
}
|
||
|
||
export const defaultWgCreateForm = (): WgCreateFormState => ({
|
||
serverId: "",
|
||
name: "",
|
||
listenPort: "13231",
|
||
mtu: "1420",
|
||
comment: "",
|
||
address: "",
|
||
enabled: true,
|
||
showAdvanced: false,
|
||
peerEnabled: false,
|
||
peerPublicKey: "",
|
||
peerAllowedIps: "",
|
||
peerEndpoint: "",
|
||
peerKeepalive: "25",
|
||
peerComment: "",
|
||
})
|
||
|
||
type ServerOption = { id: string; name: string; host: string }
|
||
|
||
function WgCreateSheet({
|
||
open,
|
||
onOpenChange,
|
||
servers,
|
||
busy,
|
||
error,
|
||
defaultServerId,
|
||
onSubmit,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (v: boolean) => void
|
||
servers: ServerOption[]
|
||
busy?: boolean
|
||
error?: string | null
|
||
defaultServerId?: string
|
||
onSubmit: (form: WgCreateFormState) => void | Promise<void>
|
||
}) {
|
||
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
|
||
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
|
||
setForm((f) => ({ ...f, [k]: v }))
|
||
|
||
const [context, setContext] = useState({ open: false, defaultServerId })
|
||
if (context.open !== open || context.defaultServerId !== defaultServerId) {
|
||
setContext({ open, defaultServerId })
|
||
if (open) setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
|
||
}
|
||
|
||
const canSubmit = useMemo(() => {
|
||
return Boolean(form.serverId && form.name.trim() && form.listenPort)
|
||
}, [form.serverId, form.name, form.listenPort])
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={v => { if (!busy) onOpenChange(v) }}>
|
||
<SheetContent showCloseButton={!busy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-md flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
|
||
<SheetDescription>
|
||
Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS)
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<fieldset disabled={busy} aria-busy={busy} className="min-h-0 min-w-0 flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Основные</SectionTitle>
|
||
<FormField label="Сервер" required>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||
aria-label="Сервер"
|
||
value={form.serverId}
|
||
onChange={(e) => set("serverId", e.target.value)}
|
||
>
|
||
<option value="">Выберите сервер…</option>
|
||
{servers.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name} ({s.host})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Имя интерфейса" required hint="Например wg-msk-spb">
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="wg0"
|
||
aria-label="Имя интерфейса" value={form.name}
|
||
onChange={(e) => set("name", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FormField label="Listen port" required>
|
||
<Input
|
||
className="font-mono"
|
||
aria-label="Listen port" value={form.listenPort}
|
||
onChange={(e) => set("listenPort", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="MTU">
|
||
<Input
|
||
className="font-mono"
|
||
aria-label="MTU" value={form.mtu}
|
||
onChange={(e) => set("mtu", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="Комментарий">
|
||
<Input
|
||
aria-label="Комментарий" value={form.comment}
|
||
onChange={(e) => set("comment", e.target.value)}
|
||
placeholder="MSK → SPB overlay"
|
||
/>
|
||
</FormField>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Включён</p>
|
||
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
|
||
</div>
|
||
<Switch aria-label="Интерфейс включён" disabled={busy} checked={form.enabled} onCheckedChange={(v) => set("enabled", v)} />
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||
onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||
>
|
||
{form.showAdvanced ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
|
||
Дополнительно
|
||
</button>
|
||
|
||
{form.showAdvanced && (
|
||
<div className="flex flex-col gap-4">
|
||
<FormField label="IP на интерфейсе" hint="IPv4 или IPv6 с префиксом, например fd00::1/64">
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="IPv4/IPv6 с префиксом"
|
||
aria-label="IP на интерфейсе" value={form.address}
|
||
onChange={(e) => set("address", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Добавить первого пира</p>
|
||
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
|
||
</div>
|
||
<Switch aria-label="Добавить первого пира" disabled={busy} checked={form.peerEnabled} onCheckedChange={(v) => set("peerEnabled", v)} />
|
||
</div>
|
||
|
||
{form.peerEnabled && (
|
||
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
|
||
<FormField label="Public key пира" required>
|
||
<Input
|
||
className="font-mono text-xs"
|
||
aria-label="Public key пира" value={form.peerPublicKey}
|
||
onChange={(e) => set("peerPublicKey", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="Allowed IPs" required hint="Через запятую">
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="10.210.0.2/32"
|
||
aria-label="Allowed IPs пира" value={form.peerAllowedIps}
|
||
onChange={(e) => set("peerAllowedIps", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="Endpoint" hint="host:port">
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="1.2.3.4:13231"
|
||
aria-label="Endpoint пира" value={form.peerEndpoint}
|
||
onChange={(e) => set("peerEndpoint", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="Keepalive (сек)">
|
||
<Input
|
||
className="font-mono"
|
||
aria-label="Keepalive пира" value={form.peerKeepalive}
|
||
onChange={(e) => set("peerKeepalive", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="Комментарий пира">
|
||
<Input
|
||
aria-label="Комментарий пира" value={form.peerComment}
|
||
onChange={(e) => set("peerComment", e.target.value)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</fieldset>
|
||
{busy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание подтверждения создания…</p>}
|
||
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||
Отмена
|
||
</SheetClose>
|
||
<Button
|
||
type="button"
|
||
className="flex-1"
|
||
disabled={!canSubmit || busy}
|
||
onClick={() => void onSubmit(form)}
|
||
>
|
||
{busy ? "Создание…" : "Создать туннель"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
export { WgCreateSheet }
|