feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+262
View File
@@ -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 }
+196
View File
@@ -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 }
+156
View File
@@ -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 }
+290
View File
@@ -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 }
+298
View File
@@ -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 }
+269
View File
@@ -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 }