"use client"
import { useCallback, useEffect, useMemo, useState } 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 { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
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 {
ShieldCheckIcon,
BadgeCheckIcon,
AlertTriangleIcon,
AlertCircleIcon,
PlusIcon,
RefreshCwIcon,
UploadIcon,
} from "lucide-react"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
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 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.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+.
)
}
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) && (
)}
{(showAll || step === 3) && (
Импорт на RouterOS
Trust store · www
Веб-интерфейс и HTTPS-сервисы
Trust store · api
REST API и управление
)}
{(showAll || step === 4) && (
Проверьте параметры
- Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}
- Имя: {issueCertName || "—"}
- CN: {issueCommonName || "—"}
- Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}
)}
)
}
export default function CertificatesPage() {
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [certificates, setCertificates] = useState([])
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
const [loadError, setLoadError] = useState(null)
const [serverList, setServerList] = useState([])
const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("")
const [issueCommonName, setIssueCommonName] = useState("")
const [issueSans, setIssueSans] = useState("")
const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory",
)
const [acmeZoneId, setAcmeZoneId] = useState("")
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
const displayCerts = useMemo(() => {
if (!prefsHydrated) return []
if (isLive) return certificates
return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, certificates])
const serverById = useMemo(() => {
const map = new Map()
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
return map
}, [isLive, serverList])
const loadLive = useCallback(
async (silent = false) => {
if (!isLive) return
if (!silent) setLoadState("loading")
setLoadError(null)
try {
const [certRes, serversRes] = await Promise.all([
listCertificates(backendUrl),
listServers(backendUrl),
])
setCertificates(certRes.certificates)
setServerList(serversRes.map(toFrontendServer))
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
setLoadError(
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
)
} else if (certRes.failures.length > 0) {
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
}
setLoadState("idle")
} catch (e) {
setCertificates([])
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
setLoadState("error")
}
},
[backendUrl, isLive],
)
const loadAcmeSettings = useCallback(async () => {
if (!liveReady) return
try {
const s = await getAcmeSettings(backendUrl)
setAcmeDirectoryUrl(s.directoryUrl)
setAcmeZoneId(s.defaultZoneId ?? "")
setAcmeTokenConfigured(s.tokenConfigured)
} catch {
/* ignore */
}
}, [backendUrl, liveReady])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setCertificates([])
setServerList([])
setLoadState("idle")
setLoadError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
void loadAcmeSettings()
})
}, [isLive, loadLive, loadAcmeSettings])
const expiring = useMemo(
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[displayCerts],
)
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
const filtered = useMemo(() => {
return displayCerts.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.commonName.toLowerCase().includes(q) ||
c.issuedBy.toLowerCase().includes(q) ||
c.sans.some((s) => s.includes(q))
)
})
}, [displayCerts, search, statusFilter])
async function handleRefresh() {
if (!liveReady) return
try {
const res = await refreshCertificates(backendUrl)
setCertificates(res.certificates)
toast.success("Список сертификатов обновлён")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
}
}
async function pollIssueJob(jobId: string) {
for (let i = 0; i < 120; i++) {
await new Promise((r) => setTimeout(r, 2000))
const job = await getCertificateIssueJob(backendUrl, jobId)
if (job.status === "done") {
toast.success("Сертификат выпущен и импортирован на роутер")
setIssueOpen(false)
await loadLive(true)
return
}
if (job.status === "failed") {
throw new Error(job.error ?? "Выпуск не удался")
}
}
throw new Error("Таймаут ожидания выпуска сертификата")
}
async function handleIssue() {
if (!liveReady) return
const domains = [
issueCommonName.trim(),
...issueSans.split(/[\n,;]+/).map((s) => s.trim()),
].filter(Boolean)
if (!issueServerId || !issueCertName.trim() || domains.length === 0) {
toast.error("Укажите сервер, имя сертификата и домены")
return
}
setIssueBusy(true)
try {
const trustStore: Array<"www" | "api"> = []
if (issueTrustWww) trustStore.push("www")
if (issueTrustApi) trustStore.push("api")
const { jobId } = await createCertificateIssueJob(backendUrl, {
serverId: issueServerId,
certName: issueCertName.trim(),
domainNames: domains,
trustStore,
})
toast.message("Выпуск сертификата запущен…")
await pollIssueJob(jobId)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка выпуска")
} finally {
setIssueBusy(false)
}
}
async function handleSaveAcmeSettings() {
if (!liveReady) return
setAcmeSaveBusy(true)
try {
const saved = await putAcmeSettings(backendUrl, {
directoryUrl: acmeDirectoryUrl.trim(),
defaultZoneId: acmeZoneId.trim() || null,
cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined,
})
setAcmeTokenConfigured(saved.tokenConfigured)
setAcmeTokenDraft("")
toast.success("Настройки ACME сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки")
} finally {
setAcmeSaveBusy(false)
}
}
async function handleTestAcme() {
if (!liveReady) return
try {
const res = await testAcmeSettings(backendUrl, {
cloudflareApiToken: acmeTokenDraft.trim() || undefined,
})
if (res.ok) toast.success(res.message ?? "Cloudflare API доступен")
else toast.error(res.message ?? "Проверка не прошла")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась")
}
}
return (
>
}
/>
{isLive && backendStatus === false && (
Backend недоступен — live-операции отключены.
)}
{loadError && isLive && (
{loadError}
)}
{(expiring.length > 0 || expired.length > 0) && (
{expired.length > 0 && }
{expiring.length > 0 && }
)}
{liveReady && (
{
void handleTestAcme()
}}
onSave={() => {
void handleSaveAcmeSettings()
}}
/>
)}
{ setIssueOpen(v); if (!v) setIssueStep(1) }}>
Выпуск сертификата
Let's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
{[
{ step: 1, title: "Основные" },
{ step: 2, title: "Домены" },
{ step: 3, title: "Импорт" },
{ step: 4, title: "Проверка" },
].map(({ step, title }, i, arr) => (
{step}
{title}
{i < arr.length - 1 && }
))}
{[1, 2, 3, 4].map((s) => (
))}
}>
Отмена
{issueStep > 1 && (
)}
{issueStep < 4 ? (
) : (
)}
{
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
)
}