Files
MikrotikManager/components/ipsec/ipsec-user-sheet.tsx
T
DenozordecandCursor 0e1524c600
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:35 +07:00

259 lines
10 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
export type IpsecAuthKind = "certificate" | "pre-shared-key"
export type IpsecUserFormState = {
serverId: string
name: string
peerName: string
authMethod: IpsecAuthKind
psk: string
remoteId: string
useStaticIp: boolean
staticIp: string
passphrase: string
}
export const defaultIpsecUserForm = (): IpsecUserFormState => ({
serverId: "",
name: "",
peerName: "",
authMethod: "certificate",
psk: "",
remoteId: "",
useStaticIp: false,
staticIp: "",
passphrase: "",
})
type ServerOption = { id: string; name: string; host: string }
export type PeerOption = { serverId: string; name: string; managed: boolean }
function IpsecUserSheet({
open,
onOpenChange,
servers,
peers,
busy,
defaultServerId,
editing,
freeIpHint,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
/** Доступные peers (серверы) для привязки identity при создании. */
peers?: PeerOption[]
busy?: boolean
defaultServerId?: string
/** Режим редактирования: сервер и метод аутентификации не меняются. */
editing?: IpsecClientDto | null
freeIpHint?: string
onSubmit: (form: IpsecUserFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecUserFormState>(defaultIpsecUserForm)
const set = <K extends keyof IpsecUserFormState>(k: K, v: IpsecUserFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
if (editing) {
const edit = editing
queueMicrotask(() => setForm({
serverId: edit.serverId,
name: edit.name,
peerName: edit.peerName ?? "",
authMethod: edit.authMethod,
psk: "",
remoteId: edit.remoteId ?? "",
useStaticIp: Boolean(edit.staticIp),
staticIp: edit.staticIp ?? "",
passphrase: "",
}))
return
}
queueMicrotask(() => setForm({ ...defaultIpsecUserForm(), serverId: defaultServerId ?? "" }))
}, [open, defaultServerId, editing])
const canSubmit = useMemo(() => {
if (!editing && !form.serverId) return false
if (!form.name.trim()) return false
if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false
if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false
return true
}, [form, editing])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent 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>{editing ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"}</SheetTitle>
<SheetDescription>
{editing
? "Имя, статический IP и secret (для PSK)"
: "Identity на роутере; для сертификата — выпуск .p12 для авторизации"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
{!editing ? (
<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]"
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>
) : null}
{!editing && (peers ?? []).some((p) => p.serverId === form.serverId) ? (
<FormField label="Peer (сервер)" hint="К какому peer привязать identity">
<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]"
value={form.peerName}
onChange={(e) => set("peerName", e.target.value)}
>
<option value="">Автоматически (managed/первый)</option>
{(peers ?? [])
.filter((p) => p.serverId === form.serverId)
.map((p) => (
<option key={`${p.serverId}:${p.name}`} value={p.name}>
{p.name}{p.managed ? "" : " (RouterOS)"}
</option>
))}
</select>
</FormField>
) : null}
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
<Input
placeholder="alice"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
{!editing ? (
<FormField
label="Аутентификация"
required
hint={form.authMethod === "certificate"
? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
: undefined}
>
<div className="flex gap-1.5">
<Button
type="button"
size="sm"
variant={form.authMethod === "certificate" ? "secondary" : "ghost"}
onClick={() => set("authMethod", "certificate")}
>
Сертификат
</Button>
<Button
type="button"
size="sm"
variant={form.authMethod === "pre-shared-key" ? "secondary" : "ghost"}
onClick={() => set("authMethod", "pre-shared-key")}
>
PSK
</Button>
</div>
</FormField>
) : null}
{form.authMethod === "pre-shared-key" ? (
<>
<FormField label={editing ? "Новый secret (PSK)" : "Secret (PSK)"} hint={editing ? "Пусто — не менять" : "Минимум 8 символов"} required={!editing}>
<Input
className="font-mono"
type="password"
value={form.psk}
onChange={(e) => set("psk", e.target.value)}
/>
</FormField>
{!editing ? (
<FormField label="Remote ID" hint="По умолчанию — имя клиента">
<Input
className="font-mono"
placeholder="alice"
value={form.remoteId}
onChange={(e) => set("remoteId", e.target.value)}
/>
</FormField>
) : null}
</>
) : (
!editing ? (
<FormField label="Пароль архива .p12" hint="Пусто — сгенерируем автоматически">
<Input
className="font-mono"
placeholder="например MySecret123"
value={form.passphrase}
onChange={(e) => set("passphrase", e.target.value)}
/>
</FormField>
) : null
)}
</div>
<div className="flex flex-col gap-4">
<SectionTitle>IP-адрес</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Статический IP</p>
<p className="text-xs text-muted-foreground">
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
{" "}Статический IP создаёт персональную identity на peer.
</p>
</div>
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
</div>
{form.useStaticIp && (
<FormField label="IP клиента" hint="Например 10.77.0.10">
<Input
className="font-mono"
placeholder={freeIpHint ?? "10.77.0.10"}
value={form.staticIp}
onChange={(e) => set("staticIp", e.target.value)}
/>
</FormField>
)}
</div>
</div>
<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
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
>
{busy ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecUserSheet }