"use client" import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FileImportDialog } from "@/components/file-import-dialog" import { routerCertificates, servers as mockServers } from "@/lib/data" import type { CertStatus, Server } from "@/lib/data" import type { CertificateDto } from "@mmapp/contracts/certificates" import { Flag } from "@/components/flag" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" import { createCertificateIssueJob, getAcmeSettings, getCertificateIssueJob, listCertificates, putAcmeSettings, refreshCertificates, testAcmeSettings, } from "@/shared/api/certificates" import { toast } from "sonner" import { SearchIcon, ShieldCheckIcon, ShieldAlertIcon, ShieldOffIcon, BadgeCheckIcon, AlertTriangleIcon, AlertCircleIcon, CalendarIcon, KeyRoundIcon, ServerIcon, PlusIcon, ChevronDownIcon, ChevronRightIcon, RefreshCwIcon, UploadIcon, } from "lucide-react" import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTitle, StepperTrigger, } from "@/components/reui/stepper" const STATUS_CONFIG: Record< CertStatus, { label: string icon: ReactNode badge: string row: string } > = { valid: { label: "Действителен", icon: , badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", row: "", }, expired: { label: "Истёк", icon: , badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", row: "bg-red-500/5", }, revoked: { label: "Отозван", icon: , badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", row: "bg-amber-500/5", }, } const CERT_TABLE_GRID_CLASS = "grid grid-cols-[1.25rem_minmax(0,1.35fr)_minmax(0,0.85fr)_minmax(0,1fr)_9.5rem_minmax(0,8.5rem)_6rem] gap-3" function daysLeftColor(days: number): string { if (days < 0) return "text-red-500" if (days <= 7) return "text-red-500" if (days <= 30) return "text-amber-500" return "text-emerald-600 dark:text-emerald-400" } function daysLeftBar(days: number, total = 365): number { if (days <= 0) return 0 return Math.min(100, Math.round((days / total) * 100)) } function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto { return { id: cert.id, name: cert.name, serverId: cert.serverId, commonName: cert.commonName, sans: cert.sans, issuedBy: cert.issuedBy, validFrom: cert.validFrom, validUntil: cert.validUntil, daysLeft: cert.daysLeft, keySize: cert.keySize, usage: cert.usage, trusted: cert.trusted, status: cert.status, } } function CertPartDaysBar({ cert, pct }: { cert: CertificateDto; pct: number }) { return (
) } function CertPartDays({ cert, pct }: { cert: CertificateDto; pct: number }) { return ( <>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`} {cert.validUntil}
) } function CertPartDetailDates({ cert }: { cert: CertificateDto }) { return (

Действителен с

{cert.validFrom}

) } function CertPartDetailSans({ cert }: { cert: CertificateDto }) { return (

SAN / Alt Names

{cert.sans.length > 0 ? cert.sans.map((s) => ( {s} )) : }
) } function CertPartDetailTrusted({ cert }: { cert: CertificateDto }) { return (

Trusted

{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}

) } function CertListRow({ cert, cfg, pct, server, expanded, onToggle, }: { cert: CertificateDto cfg: (typeof STATUS_CONFIG)[CertStatus] pct: number server?: Server expanded: boolean onToggle: () => void }) { return (
{cfg.icon} {cert.name}

{cert.commonName}

{server ? ( <> {server.name} ) : ( <> {cert.serverName ?? cert.serverId} )}

{cert.issuedBy}

{cert.usage.map((u) => ( {u} ))}
{cfg.label}
{expanded && (

Key size

{cert.keySize} bit

)}
) } function CertRow({ cert, server, expanded, onToggle, }: { cert: CertificateDto server?: Server expanded: boolean onToggle: () => void }) { const cfg = STATUS_CONFIG[cert.status] const pct = daysLeftBar(cert.daysLeft) return ( ) } function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) { return (

{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}

{expired.map((c) => c.name).join(", ")} — требуют обновления

) } function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) { return (

{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней

{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}

) } function CertPartKpi({ displayCerts, expiring, expired, }: { displayCerts: CertificateDto[] expiring: CertificateDto[] expired: CertificateDto[] }) { return (
{[ { label: "Всего", value: displayCerts.length, icon: , }, { label: "Действующих", value: displayCerts.filter((c) => c.status === "valid").length, icon: , }, { label: "Истекают", value: expiring.length, icon: , }, { label: "Истёкших", value: expired.length, icon: , }, ].map((s) => (

{s.label}

{s.value}

{s.icon}
))}
) } function CertPartAcmeSettings({ acmeDirectoryUrl, setAcmeDirectoryUrl, acmeZoneId, setAcmeZoneId, acmeTokenDraft, setAcmeTokenDraft, acmeTokenConfigured, acmeSaveBusy, onTest, onSave, }: { acmeDirectoryUrl: string setAcmeDirectoryUrl: (v: string) => void acmeZoneId: string setAcmeZoneId: (v: string) => void acmeTokenDraft: string setAcmeTokenDraft: (v: string) => void acmeTokenConfigured: boolean acmeSaveBusy: boolean onTest: () => void onSave: () => void }) { return (

ACME · Cloudflare DNS-01

Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.

event.preventDefault()} >
setAcmeDirectoryUrl(e.target.value)} />
setAcmeZoneId(e.target.value)} placeholder="Авто по домену" />
setAcmeTokenDraft(e.target.value)} placeholder={ acmeTokenConfigured ? "Токен сохранён — введите новый для замены" : "API token с правом DNS" } />
) } function CertPartTableToolbar({ search, setSearch, statusFilter, setStatusFilter, filteredCount, }: { search: string setSearch: (v: string) => void statusFilter: CertStatus | "all" setStatusFilter: (v: CertStatus | "all") => void filteredCount: number }) { return (
setSearch(e.target.value)} />
{(["all", "valid", "expired", "revoked"] as const).map((s) => ( ))}
{filteredCount} сертификатов
) } function CertPartTableHeaderDates() { return (
Срок
) } function CertPartTableHeaderUsage() { return (
Использование
) } function CertPartTableHeader() { return (
Имя / CN Сервер Выпущен Статус
) } function CertPartReference() { return (

RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве.

RouterOS 7 · /certificate — справка CLI

{[ { title: "Создать CA", lines: [ "/certificate add \\", " name=my-ca \\", " common-name=MyCA \\", " key-size=4096 \\", " days-valid=3650 \\", " key-usage=key-cert-sign,crl-sign", "/certificate sign my-ca", ], }, { title: "Импорт LE", lines: [ "/certificate import \\", " file-name=router.crt \\", " name=router-cert \\", " trusted=yes \\", " trust-store=www,api", ], }, { title: "Статус", lines: ["/certificate print detail", "/certificate export-certificate router-cert"], }, ].map((b) => (

{b.title}

                {b.lines.join("\n")}
              
))}
) } function CertPartIssueForm({ serverList, issueServerId, setIssueServerId, issueCertName, setIssueCertName, issueCommonName, setIssueCommonName, issueSans, setIssueSans, issueTrustWww, setIssueTrustWww, issueTrustApi, setIssueTrustApi, step, }: { serverList: Server[] issueServerId: string setIssueServerId: (v: string) => void issueCertName: string setIssueCertName: (v: string) => void issueCommonName: string setIssueCommonName: (v: string) => void issueSans: string setIssueSans: (v: string) => void issueTrustWww: boolean setIssueTrustWww: (v: boolean) => void issueTrustApi: boolean setIssueTrustApi: (v: boolean) => void step?: 1 | 2 | 3 | 4 }) { const showAll = step == null return (
{(showAll || step === 1) && (
Основные setIssueCertName(e.target.value)} placeholder="router-le" />
)} {(showAll || step === 2) && (
Домены setIssueCommonName(e.target.value)} placeholder="vpn.example.com" />