Files

197 lines
7.7 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { InfoIcon } from "lucide-react"
export type IpsecInitFormState = {
serverId: string
serverEndpoint: string
poolCidr: string
dns: string
createNatRule: boolean
}
export const defaultIpsecInitForm = (): IpsecInitFormState => ({
serverId: "",
serverEndpoint: "",
poolCidr: "10.77.0.0/24",
dns: "",
createNatRule: true,
})
type ServerOption = { id: string; name: string; host: string }
function IpsecInitSheet({
open,
onOpenChange,
servers,
busy,
defaultServerId,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onSubmit: (form: IpsecInitFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecInitFormState>(defaultIpsecInitForm)
const submittingRef = useRef(false)
const openedRef = useRef(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const pending = busy || submitting
const set = <K extends keyof IpsecInitFormState>(k: K, v: IpsecInitFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) { openedRef.current = false; return }
if (openedRef.current) return
openedRef.current = true
const server = servers.find((s) => s.id === (defaultServerId ?? ""))
queueMicrotask(() => {
setError(null)
setForm({
...defaultIpsecInitForm(),
serverId: defaultServerId ?? "",
serverEndpoint: server?.host ?? "",
})
})
}, [open, defaultServerId, servers])
const canSubmit = useMemo(() => {
return Boolean(
form.serverId &&
form.serverEndpoint.trim() &&
(/^(?:\d{1,3}\.){3}\d{1,3}$/.test(form.serverEndpoint.trim())
? form.serverEndpoint.trim().split(".").every(part => Number(part) <= 255)
: /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(form.serverEndpoint.trim())) &&
/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()),
)
}, [form])
const submit = async () => {
if (!canSubmit || busy || submittingRef.current) return
submittingRef.current = true
setSubmitting(true)
setError(null)
try {
await onSubmit(form)
} catch (error) {
setError(error instanceof Error ? error.message : "Не удалось инициализировать сервер")
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
return (
<Sheet open={open} onOpenChange={value => { if (!submittingRef.current && !busy) onOpenChange(value) }}>
<SheetContent showCloseButton={!pending} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Инициализация IKEv2-сервера</SheetTitle>
<SheetDescription>
CA + серверный сертификат, peer (ike2/passive), пул адресов и mode-config
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<Alert variant="info">
<InfoIcon />
<AlertTitle>Выпуск CA и сертификатов занимает до минуты</AlertTitle>
<AlertDescription>
Повторный запуск безопасен: существующие managed-объекты обновятся, сертификаты
не перевыпускаются.
</AlertDescription>
</Alert>
<fieldset disabled={pending} className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required>
<select
aria-label="Сервер"
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]"
value={form.serverId}
onChange={(e) => {
const id = e.target.value
const server = servers.find((s) => s.id === id)
setForm((f) => ({ ...f, serverId: id, serverEndpoint: server?.host ?? f.serverEndpoint }))
}}
>
<option value="">Выберите сервер…</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Адрес сервера (CN/SAN)" required hint="Домен или IP — по нему подключаются клиенты">
<Input
className="font-mono"
placeholder="vpn.example.com"
aria-label="Адрес сервера (CN/SAN)"
value={form.serverEndpoint}
onChange={(e) => set("serverEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Подсеть клиентов" required hint="Из неё пул .2–.254 и статические IP">
<Input
className="font-mono"
placeholder="10.77.0.0/24"
aria-label="Подсеть клиентов"
value={form.poolCidr}
onChange={(e) => set("poolCidr", e.target.value)}
/>
</FormField>
<FormField label="DNS для клиентов" hint="Например 10.77.0.1 или 1.1.1.1">
<Input
className="font-mono"
aria-label="DNS для клиентов"
value={form.dns}
onChange={(e) => set("dns", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Интернет клиентам (NAT)</p>
<p className="text-xs text-muted-foreground">
Managed srcnat masquerade для подсети клиентов (out-interface-list WAN)
</p>
</div>
<Switch aria-label="Интернет клиентам (NAT)" disabled={pending} checked={form.createNatRule} onCheckedChange={(v) => set("createNatRule", v)} />
</div>
</fieldset>
{pending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата инициализации…</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={pending} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!canSubmit || pending}
onClick={() => void submit()}
>
{pending ? "Инициализация…" : "Инициализировать"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecInitSheet }