Files
MikrotikManager/components/ipsec/ipsec-server-grid.tsx
T
Denozordec 3eb75ea0b8
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m41s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m48s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
feat(ipsec): add certificate export functionality by name
- Implemented a new backend route for exporting existing client certificates in .p12 format by name.
- Enhanced the frontend to support exporting certificates directly from the IPsec server grid.
- Updated the IPsec page to manage certificate states and handle exports effectively.
- Introduced new utility functions for certificate handling and improved data structures to accommodate the changes.
- Added tests to ensure the reliability of the new certificate export feature.
2026-09-12 21:39:14 +07:00

287 lines
10 KiB
TypeScript

"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 && cert.role === "client" ? (
<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 {
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,
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 ? (peer) => onEditPeer(s.serverId, peer) : undefined}
onDelete={onDeletePeer ? (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 ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
onExport={onExportCert ? (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.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"
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 + сертификаты)"
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 }