feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
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"
|
||||
|
||||
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 lock = useRef(false)
|
||||
const opened = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = busy || submitting
|
||||
const set = <K extends keyof IpsecUserFormState>(k: K, v: IpsecUserFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { opened.current = false; return }
|
||||
if (opened.current) return
|
||||
opened.current = true
|
||||
queueMicrotask(() => setError(null))
|
||||
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 (form.authMethod === "pre-shared-key" && (!editing || form.psk.trim()) && 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
|
||||
// RouterOS отклоняет export-passphrase короче 8 символов
|
||||
const passphrase = form.passphrase.trim()
|
||||
if (!editing && passphrase.length > 0 && passphrase.length < IPSEC_MIN_PASSPHRASE) return false
|
||||
return true
|
||||
}, [form, editing])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || busy || lock.current) return
|
||||
lock.current = true
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try { await onSubmit(form) }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось сохранить клиента") }
|
||||
finally { lock.current = false; setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={value => { if (!lock.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>{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">
|
||||
<fieldset disabled={pending} className="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]"
|
||||
aria-label="Сервер"
|
||||
value={form.serverId}
|
||||
onChange={(e) => setForm(f => ({ ...f, serverId: e.target.value, peerName: "" }))}
|
||||
>
|
||||
<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]"
|
||||
aria-label="Peer (сервер)"
|
||||
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"
|
||||
aria-label="Имя клиента"
|
||||
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"
|
||||
aria-label="Secret (PSK)"
|
||||
value={form.psk}
|
||||
onChange={(e) => set("psk", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{!editing ? (
|
||||
<FormField label="Remote ID" hint="По умолчанию — имя клиента">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="alice"
|
||||
aria-label="Remote ID"
|
||||
value={form.remoteId}
|
||||
onChange={(e) => set("remoteId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
!editing ? (
|
||||
<FormField
|
||||
label="Пароль архива .p12"
|
||||
hint={
|
||||
form.passphrase.trim().length > 0 && form.passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Пусто — сгенерируем автоматически (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="например MySecret123"
|
||||
aria-label="Пароль архива .p12"
|
||||
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>
|
||||
<Switch aria-label="Статический IP" disabled={pending} checked={form.useStaticIp} onCheckedChange={(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"}
|
||||
aria-label="IP клиента"
|
||||
value={form.staticIp}
|
||||
onChange={(e) => set("staticIp", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</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 ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecUserSheet }
|
||||
Reference in new issue
Block a user