feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||
import type { IpsecExistingCertExportOptions } from "@/shared/api/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { toast } from "sonner"
|
||||
import { DownloadIcon, CopyIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
function downloadBlob(filename: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
downloadBlob(filename, new Blob([content], { type: "text/plain;charset=utf-8" }))
|
||||
}
|
||||
|
||||
function downloadB64(filename: string, b64: string, mime: string) {
|
||||
const bin = atob(b64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
downloadBlob(filename, new Blob([bytes], { type: mime }))
|
||||
}
|
||||
|
||||
function randomPassphrase(): string {
|
||||
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
|
||||
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
|
||||
crypto.getRandomValues(bytes)
|
||||
let s = ""
|
||||
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
|
||||
return s
|
||||
}
|
||||
|
||||
function IpsecCertSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
bundle,
|
||||
busy,
|
||||
error,
|
||||
onReexport,
|
||||
certificateRole,
|
||||
targetName,
|
||||
previousAttemptFailed = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
bundle: IpsecCertBundle | null
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
||||
onReexport?: (passphrase: string, options: IpsecExistingCertExportOptions) => void | Promise<void>
|
||||
certificateRole?: "ca" | "server" | "client" | "other"
|
||||
targetName?: string
|
||||
previousAttemptFailed?: boolean
|
||||
}) {
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false)
|
||||
const [privateKeyConfirmed, setPrivateKeyConfirmed] = useState(false)
|
||||
const [caKeyConfirmed, setCaKeyConfirmed] = useState(false)
|
||||
const [newAttemptConfirmed, setNewAttemptConfirmed] = useState(false)
|
||||
const submitting = useRef(false)
|
||||
const isCa = certificateRole === "ca"
|
||||
const confirmEnabled = privateKeyConfirmed && (!isCa || caKeyConfirmed) && (!previousAttemptFailed || newAttemptConfirmed) && !busy
|
||||
const openExportConfirmation = () => {
|
||||
if (busy || submitting.current) return
|
||||
setPrivateKeyConfirmed(false)
|
||||
setCaKeyConfirmed(false)
|
||||
setNewAttemptConfirmed(false)
|
||||
setConfirmationOpen(true)
|
||||
}
|
||||
const confirmExport = async () => {
|
||||
if (!confirmEnabled || !onReexport || submitting.current) return
|
||||
submitting.current = true
|
||||
setConfirmationOpen(false)
|
||||
try {
|
||||
// One key per explicitly confirmed attempt. Nothing retries this call or
|
||||
// allocates another key after an error, opening, selection, or rendering.
|
||||
const requestKey = crypto.randomUUID()
|
||||
await onReexport(randomPassphrase(), { privateKeyExport: true, caPrivateKeyExport: isCa && caKeyConfirmed, requestKey })
|
||||
} catch {
|
||||
toast.error("Экспорт не подтверждён. Перед новой попыткой проверьте состояние роутера.")
|
||||
} finally {
|
||||
submitting.current = false
|
||||
}
|
||||
}
|
||||
const passphrase = bundle?.passphrase ?? ""
|
||||
const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE)
|
||||
|
||||
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>Сертификат {bundle?.user || targetName ? `«${bundle?.user ?? targetName}»` : ""}</SheetTitle>
|
||||
<SheetDescription>
|
||||
.p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Загрузка сертификата…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{previousAttemptFailed && !busy && (
|
||||
<p role="alert" className="text-sm text-destructive">Предыдущая попытка завершилась ошибкой; её результат на роутере может быть неизвестен. Проверьте временный файл и состояние экспорта перед новым запросом.</p>
|
||||
)}
|
||||
{!bundle ? (
|
||||
!busy && onReexport ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Экспорт .p12 включает закрытый ключ. Подтвердите его получение перед запросом к роутеру.</p>
|
||||
<Button variant="outline" onClick={openExportConfirmation}>{previousAttemptFailed ? "Подготовить новый запрос" : "Экспортировать .p12"}</Button>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Пароль архива .p12</SectionTitle>
|
||||
<FormField
|
||||
label="Passphrase"
|
||||
required
|
||||
hint={
|
||||
passphrase.trim().length > 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={passphrase}
|
||||
readOnly
|
||||
aria-label="Пароль архива .p12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="Подтвердить экспорт с новым паролем"
|
||||
aria-label="Подтвердить экспорт с новым паролем"
|
||||
disabled={busy || !onReexport}
|
||||
onClick={openExportConfirmation}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
{bundle.serverEndpoint ? (
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
Сервер: <span className="font-mono">{bundle.serverEndpoint}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Файлы</SectionTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle) return
|
||||
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
|
||||
toast.success(`Скачан ${bundle.filename}`)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="truncate">{bundle.filename} (.p12, сертификат + ключ)</span>
|
||||
</Button>
|
||||
{bundle.sswanContent && bundle.sswanFilename ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle.sswanContent || !bundle.sswanFilename) return
|
||||
downloadText(bundle.sswanFilename, bundle.sswanContent)
|
||||
toast.success(`Скачан ${bundle.sswanFilename}`)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="truncate">{bundle.sswanFilename} (strongSwan)</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{bundle.instructions ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start text-muted-foreground"
|
||||
onClick={() => {
|
||||
if (!bundle.instructions) return
|
||||
void navigator.clipboard?.writeText(bundle.instructions)
|
||||
toast.success("Инструкция скопирована")
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
Скопировать инструкцию по подключению
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bundle.instructions ? (
|
||||
<pre className="max-h-72 overflow-y-auto overflow-x-hidden rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{bundle.instructions}
|
||||
</pre>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0">
|
||||
<SheetClose render={<Button variant="outline" className="w-full" />}>
|
||||
Закрыть
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Dialog open={confirmationOpen && open} onOpenChange={setConfirmationOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подтвердить экспорт {isCa ? "CA" : "сертификата"}</DialogTitle>
|
||||
<DialogDescription>Запрос создаст на роутере временный .p12 с закрытым ключом сертификата {targetName ? `«${targetName}»` : ""}, прочитает его и удалит временный файл. Пароль архива будет сгенерирован после подтверждения.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<label htmlFor="ipsec-export-private-key" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-private-key" checked={privateKeyConfirmed} onCheckedChange={setPrivateKeyConfirmed} />
|
||||
<span>Разрешаю экспорт и получение закрытого ключа выбранного сертификата.</span>
|
||||
</label>
|
||||
{isCa && (
|
||||
<label htmlFor="ipsec-export-ca-key" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-ca-key" checked={caKeyConfirmed} onCheckedChange={setCaKeyConfirmed} />
|
||||
<span>Отдельно разрешаю экспорт закрытого ключа центра сертификации CA. Его владелец может подписывать другие сертификаты.</span>
|
||||
</label>
|
||||
)}
|
||||
{previousAttemptFailed && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-destructive">Результат предыдущего запроса может быть неизвестен. Новый запрос не продолжает и не подтверждает предыдущую попытку.</p>
|
||||
<label htmlFor="ipsec-export-new-attempt" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-new-attempt" checked={newAttemptConfirmed} onCheckedChange={setNewAttemptConfirmed} />
|
||||
<span>Я проверил состояние роутера и явно запрашиваю новый экспорт.</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmationOpen(false)}>Отмена</Button>
|
||||
<Button disabled={!confirmEnabled} onClick={() => void confirmExport()}>{previousAttemptFailed ? "Отправить новый запрос" : "Подтвердить экспорт"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecCertSheet, downloadText, downloadB64 }
|
||||
@@ -0,0 +1,196 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { IpsecPeerDto } 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 IpsecPeerFormState = {
|
||||
name: string
|
||||
address: string
|
||||
exchangeMode: string
|
||||
passive: boolean
|
||||
profile: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({
|
||||
name: "",
|
||||
address: "0.0.0.0/0",
|
||||
exchangeMode: "ike2",
|
||||
passive: true,
|
||||
profile: "",
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const EXCHANGE_MODES = ["ike2", "main", "aggressive", "base"]
|
||||
|
||||
/** Редактирование существующего peer (сервера) RouterOS. */
|
||||
function IpsecPeerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
busy,
|
||||
editing,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
busy?: boolean
|
||||
editing: IpsecPeerDto | null
|
||||
onSubmit: (form: IpsecPeerFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm)
|
||||
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 IpsecPeerFormState>(k: K, v: IpsecPeerFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { opened.current = false; return }
|
||||
if (!editing || opened.current) return
|
||||
opened.current = true
|
||||
queueMicrotask(() => setError(null))
|
||||
queueMicrotask(() => setForm({
|
||||
name: editing.name,
|
||||
address: editing.address ?? "",
|
||||
exchangeMode: editing.exchangeMode ?? "ike2",
|
||||
passive: editing.passive ?? false,
|
||||
profile: editing.profile ?? "",
|
||||
disabled: editing.disabled,
|
||||
}))
|
||||
}, [open, editing])
|
||||
|
||||
const canSubmit = useMemo(() => form.name.trim().length > 0, [form.name])
|
||||
|
||||
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 : "Не удалось сохранить peer") }
|
||||
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 ? `Peer «${editing.name}»` : "Peer"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editing?.managed
|
||||
? "Managed peer: изменения применяются напрямую на роутере"
|
||||
: "Существующий peer RouterOS: изменения применяются напрямую"}
|
||||
</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>
|
||||
<FormField label="Имя" required>
|
||||
<Input aria-label="Имя" value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Адрес" hint="Обычно 0.0.0.0/0 для road-warrior">
|
||||
<Input className="font-mono" aria-label="Адрес" value={form.address} onChange={(e) => set("address", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Exchange mode">
|
||||
<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="Exchange mode" value={form.exchangeMode}
|
||||
onChange={(e) => set("exchangeMode", e.target.value)}
|
||||
>
|
||||
{EXCHANGE_MODES.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<p className="text-xs text-muted-foreground">Сертификат аутентификации задаётся в identity. Эта форма изменяет только peer.</p>
|
||||
<FormField label="Profile" hint="Пусто — не менять">
|
||||
<Input className="font-mono" aria-label="Profile" value={form.profile} onChange={(e) => set("profile", e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Состояние</SectionTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Passive</p>
|
||||
<Switch aria-label="Passive" disabled={pending} checked={form.passive} onCheckedChange={(v) => set("passive", v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Отключён</p>
|
||||
<Switch aria-label="Отключён" disabled={pending} checked={form.disabled} onCheckedChange={(v) => set("disabled", v)} />
|
||||
</div>
|
||||
</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 { IpsecPeerSheet }
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client"
|
||||
|
||||
import type { IpsecCertInfoDto, IpsecPeerDto, IpsecServerSummaryDto } from "@mmapp/contracts/ipsec"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DownloadIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
function Row({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 py-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className={cn("truncate text-xs", mono ? "font-mono" : "")} title={value}>
|
||||
{value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OriginBadge({ managed }: { managed: boolean }) {
|
||||
return managed ? null : (
|
||||
<Badge variant="outline" className="text-[10px]">RouterOS</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function PeerRow({
|
||||
peer,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
peer: IpsecPeerDto
|
||||
onEdit?: (peer: IpsecPeerDto) => void
|
||||
onDelete?: (peer: IpsecPeerDto) => void
|
||||
}) {
|
||||
const summary = [
|
||||
peer.exchangeMode,
|
||||
peer.address,
|
||||
peer.passive ? "passive" : undefined,
|
||||
peer.certificate,
|
||||
peer.profile,
|
||||
].filter(Boolean).join(" · ")
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-xs font-medium">{peer.name}</span>
|
||||
<OriginBadge managed={peer.managed} />
|
||||
{peer.disabled ? <Badge variant="outline" className="text-[10px]">выкл</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">{summary || "—"}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
{onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Изменить peer"
|
||||
title="Изменить peer"
|
||||
onClick={() => onEdit(peer)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 text-destructive"
|
||||
aria-label="Удалить peer"
|
||||
title="Удалить peer"
|
||||
onClick={() => onDelete(peer)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertRow({
|
||||
cert,
|
||||
onDelete,
|
||||
onExport,
|
||||
}: {
|
||||
cert: IpsecCertInfoDto
|
||||
onDelete?: (cert: IpsecCertInfoDto) => void
|
||||
onExport?: (cert: IpsecCertInfoDto) => void
|
||||
}) {
|
||||
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium">{cert.name}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{roleLabel}</Badge>
|
||||
<OriginBadge managed={cert.managed} />
|
||||
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{[
|
||||
cert.commonName,
|
||||
cert.signedBy ? `подписан ${cert.signedBy}` : undefined,
|
||||
cert.expiresAt ? `до ${cert.expiresAt}` : undefined,
|
||||
].filter(Boolean).join(" · ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
{onExport ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Скачать .p12"
|
||||
title="Скачать .p12 (сертификат + ключ)"
|
||||
onClick={() => onExport(cert)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 text-destructive"
|
||||
aria-label="Удалить сертификат"
|
||||
title="Удалить сертификат"
|
||||
onClick={() => onDelete(cert)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface IpsecServerGridProps {
|
||||
canManage?: (serverId: string) => boolean
|
||||
servers: IpsecServerSummaryDto[]
|
||||
onInit?: (row: IpsecServerSummaryDto) => void
|
||||
onRemove?: (row: IpsecServerSummaryDto) => void
|
||||
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||
onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||
}
|
||||
|
||||
function IpsecServerGrid({
|
||||
servers,
|
||||
canManage,
|
||||
onInit,
|
||||
onRemove,
|
||||
onEditPeer,
|
||||
onDeletePeer,
|
||||
onDeleteCert,
|
||||
onExportCert,
|
||||
}: IpsecServerGridProps) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{servers.map((s) => {
|
||||
const certs = s.certs ?? []
|
||||
return (
|
||||
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{s.serverName}</p>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{s.serverEndpoint ?? "endpoint не определён"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
{s.ike2Ready ? (
|
||||
<Badge className="gap-1">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
IKEv2 готов
|
||||
</Badge>
|
||||
) : null}
|
||||
{s.peers.length > 0 ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
{s.peers.length} peer
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">нет peer</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Peer / серверы
|
||||
</p>
|
||||
{s.peers.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{s.peers.map((p) => (
|
||||
<PeerRow
|
||||
key={p.rosId}
|
||||
peer={p}
|
||||
onEdit={onEditPeer && (!canManage || canManage(s.serverId)) ? (peer) => onEditPeer(s.serverId, peer) : undefined}
|
||||
onDelete={onDeletePeer && (!canManage || canManage(s.serverId)) ? (peer) => onDeletePeer(s.serverId, peer) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Peers не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Сертификаты
|
||||
</p>
|
||||
{certs.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{certs.map((c) => (
|
||||
<CertRow
|
||||
key={c.name}
|
||||
cert={c}
|
||||
onDelete={onDeleteCert && (!canManage || canManage(s.serverId)) ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
|
||||
onExport={onExportCert && (!canManage || canManage(s.serverId)) ? (cert) => onExportCert(s.serverId, cert) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Сертификаты не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col divide-y divide-border/60">
|
||||
<Row label="Клиенты" value={s.clientsSessionAmbiguous ? `${s.clientsOnline} подтверждено онлайн / ${s.clientsTotal}; статус ${s.clientsSessionAmbiguous} не определён` : `${s.clientsOnline} онлайн / ${s.clientsTotal}`} />
|
||||
<Row label="Пул адресов" value={s.pool ? `${s.pool.name}: ${s.pool.ranges}` : "—"} />
|
||||
<Row label="NAT (интернет)" value={s.natRuleManaged ? "managed masquerade" : "нет правила"} mono={false} />
|
||||
<Row label="Managed IKEv2" value={s.initialized ? "инициализирован" : "не инициализирован"} mono={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onInit || (canManage && !canManage(s.serverId))}
|
||||
onClick={() => onInit?.(s)}
|
||||
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
{s.initialized ? "Переинициализировать" : "Инициализировать"}
|
||||
</Button>
|
||||
{s.initialized && onRemove ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 text-destructive"
|
||||
aria-label="Удалить IKEv2-сервер"
|
||||
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
|
||||
disabled={canManage && !canManage(s.serverId)}
|
||||
onClick={() => onRemove(s)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{servers.length === 0 ? (
|
||||
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<NetworkIcon className="size-4" />
|
||||
Серверы не найдены
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecServerGrid }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon,
|
||||
WifiIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface IpsecUsersGridProps {
|
||||
canManage?: (serverId: string) => boolean
|
||||
clients: IpsecClientDto[]
|
||||
compactServer?: boolean
|
||||
emptyAction?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onDownloadCert?: (row: IpsecClientDto) => void
|
||||
onEdit?: (row: IpsecClientDto) => void
|
||||
onDelete?: (row: IpsecClientDto) => void
|
||||
}
|
||||
|
||||
function IpsecUsersGrid({
|
||||
clients,
|
||||
canManage,
|
||||
compactServer = false,
|
||||
emptyAction,
|
||||
emptyTitle = "Нет клиентов IKEv2",
|
||||
emptyDescription = "Создайте клиента — сертификат и .p12 выпустятся автоматически",
|
||||
onDownloadCert,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: IpsecUsersGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, IpsecClientDto>[]>(() => {
|
||||
const cols: ColumnDef<DataGridFeatures, IpsecClientDto>[] = [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Клиент" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<UsersIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onEdit || (canManage && !canManage(c.serverId))}
|
||||
className="truncate text-left font-medium hover:underline"
|
||||
title={c.name}
|
||||
onClick={() => onEdit?.(c)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
{c.disabled ? (
|
||||
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
|
||||
) : null}
|
||||
{!c.managed ? (
|
||||
<Badge variant="outline" className="ml-1 text-[10px]">RouterOS</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Клиент",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
accessorKey: "authMethod",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
const cert = c.kind === "cert"
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cert ? (
|
||||
<BadgeCheckIcon className="size-3.5 shrink-0 text-info" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{cert ? "Сертификат" : "PSK"}
|
||||
</div>
|
||||
{cert && c.signedBy ? (
|
||||
<span
|
||||
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={`Подписан: ${c.signedBy}`}
|
||||
>
|
||||
подписан: {c.signedBy}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Аутентификация",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!compactServer) {
|
||||
cols.push({
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{row.original.serverName}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "ip",
|
||||
accessorFn: (row) => row.staticIp ?? "",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="IP" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs",
|
||||
row.original.staticIp ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.staticIp ?? "из пула"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
accessorKey: "online",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-1.5 rounded-full",
|
||||
row.original.online ? "bg-success" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className={cn("font-mono text-[11px]", row.original.online ? "text-success" : "text-muted-foreground")}>
|
||||
{row.original.sessionAmbiguous ? "Не определено" : row.original.online ? row.original.activeAddress ?? "онлайн" : "офлайн"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{c.kind === "cert" && onDownloadCert ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label="Скачать сертификат"
|
||||
title="Скачать .p12"
|
||||
disabled={canManage && !canManage(c.serverId)}
|
||||
onClick={() => onDownloadCert(c)}
|
||||
>
|
||||
<WifiIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
aria-label="Удалить клиента"
|
||||
disabled={canManage && !canManage(c.serverId)}
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return cols
|
||||
}, [compactServer, onDownloadCert, onEdit, onDelete, canManage])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: clients,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (clients.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={clients.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecUsersGrid }
|
||||
Reference in new issue
Block a user