Files
MikrotikManager/app/(main)/certificates/page.tsx
T

1031 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { PageHeader } from "@/components/page-header"
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,
} from "lucide-react"
const STATUS_CONFIG: Record<
CertStatus,
{
label: string
icon: ReactNode
badge: string
row: string
}
> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
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 (
<div
className={cn(
"h-full rounded-full transition-all",
cert.daysLeft < 0
? "bg-red-500"
: cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
)
}
function CertPartDays({ cert, pct }: { cert: CertificateDto; pct: number }) {
return (
<>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<CertPartDaysBar cert={cert} pct={pct} />
</div>
</>
)
}
function CertPartDetailDates({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
)
}
function CertPartDetailSans({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
<div className="flex flex-wrap gap-1">
{cert.sans.length > 0
? cert.sans.map((s) => (
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
{s}
</span>
))
: <span className="text-muted-foreground"></span>}
</div>
</div>
)
}
function CertPartDetailTrusted({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
)
}
function CertListRow({
cert,
cfg,
pct,
server,
expanded,
onToggle,
}: {
cert: CertificateDto
cfg: (typeof STATUS_CONFIG)[CertStatus]
pct: number
server?: Server
expanded: boolean
onToggle: () => void
}) {
return (
<div className={cn("border-b last:border-b-0", cfg.row)}>
<div
className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggle}
>
<button
type="button"
className="text-muted-foreground"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
</button>
<div className="min-w-0">
<div className="flex items-center gap-2">
{cfg.icon}
<span className="font-medium text-sm truncate">{cert.name}</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate">{cert.commonName}</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{server
? (
<>
<Flag code={server.country} size={12} />
<span className="font-mono truncate">{server.name}</span>
</>
)
: (
<>
<ServerIcon className="size-3.5" />
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
</>
)}
</div>
<p className="text-xs text-muted-foreground truncate">{cert.issuedBy}</p>
<div>
<CertPartDays cert={cert} pct={pct} />
</div>
<div className="flex flex-wrap gap-1">
{cert.usage.map((u) => (
<span
key={u}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
>
{u}
</span>
))}
</div>
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap", cfg.badge)}>
{cfg.label}
</span>
</div>
{expanded && (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</p>
</div>
<CertPartDetailDates cert={cert} />
<CertPartDetailSans cert={cert} />
<CertPartDetailTrusted cert={cert} />
</div>
)}
</div>
)
}
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 (
<CertListRow
cert={cert}
cfg={cfg}
pct={pct}
server={server}
expanded={expanded}
onToggle={onToggle}
/>
)
}
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-600 dark:text-red-400">
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{expired.map((c) => c.name).join(", ")} требуют обновления
</p>
</div>
</div>
)
}
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
</p>
</div>
</div>
)
}
function CertPartKpi({
displayCerts,
expiring,
expired,
}: {
displayCerts: CertificateDto[]
expiring: CertificateDto[]
expired: CertificateDto[]
}) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
label: "Всего",
value: displayCerts.length,
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
},
{
label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length,
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
},
{
label: "Истекают",
value: expiring.length,
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
},
{
label: "Истёкших",
value: expired.length,
icon: <AlertCircleIcon className="size-4 text-red-500" />,
},
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
)
}
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 (
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium">ACME · Cloudflare DNS-01</p>
<p className="text-xs text-muted-foreground">
Публичные Let&apos;s Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.
</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">ACME directory URL</label>
<Input value={acmeDirectoryUrl} onChange={(e) => setAcmeDirectoryUrl(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
<Input
value={acmeZoneId}
onChange={(e) => setAcmeZoneId(e.target.value)}
placeholder="Авто по домену"
/>
</div>
<div className="flex flex-col gap-1.5 md:col-span-2">
<label className="text-sm font-medium">Cloudflare API token</label>
<Input
type="password"
value={acmeTokenDraft}
onChange={(e) => setAcmeTokenDraft(e.target.value)}
placeholder={
acmeTokenConfigured
? "Токен сохранён — введите новый для замены"
: "API token с правом DNS"
}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
Проверить Cloudflare
</Button>
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
Сохранить настройки
</Button>
</div>
</CardContent>
</Card>
)
}
function CertPartTableToolbar({
search,
setSearch,
statusFilter,
setStatusFilter,
filteredCount,
}: {
search: string
setSearch: (v: string) => void
statusFilter: CertStatus | "all"
setStatusFilter: (v: CertStatus | "all") => void
filteredCount: number
}) {
return (
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, CN, эмитенту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "all"
? "Все"
: s === "valid"
? "Действующие"
: s === "expired"
? "Истёкшие"
: "Отозванные"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filteredCount} сертификатов</span>
</div>
)
}
function CertPartTableHeaderDates() {
return (
<div className="flex items-center gap-1">
<CalendarIcon className="size-3" />
Срок
</div>
)
}
function CertPartTableHeaderUsage() {
return (
<div className="flex items-center gap-1">
<KeyRoundIcon className="size-3" />
Использование
</div>
)
}
function CertPartTableHeader() {
return (
<div className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / CN</span>
<span>Сервер</span>
<span>Выпущен</span>
<CertPartTableHeaderDates />
<CertPartTableHeaderUsage />
<span>Статус</span>
</div>
)
}
function CertPartReference() {
return (
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-1">
RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве.
</p>
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7 · /certificate справка CLI</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
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) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title}
</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
)
}
function CertPartIssueForm({
serverList,
issueServerId,
setIssueServerId,
issueCertName,
setIssueCertName,
issueCommonName,
setIssueCommonName,
issueSans,
setIssueSans,
issueTrustWww,
setIssueTrustWww,
issueTrustApi,
setIssueTrustApi,
}: {
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
}) {
return (
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Сервер</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
>
<option value="">Выберите сервер</option>
{serverList.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Имя сертификата на роутере</label>
<Input
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Common Name</label>
<Input
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">SAN (по одному в строке)</label>
<textarea
className="min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Trust store</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={issueTrustWww}
onChange={(e) => setIssueTrustWww(e.target.checked)}
/>
www
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={issueTrustApi}
onChange={(e) => setIssueTrustApi(e.target.checked)}
/>
api
</label>
</div>
</div>
)
}
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<CertStatus | "all">("all")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [certificates, setCertificates] = useState<CertificateDto[]>([])
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
const [loadError, setLoadError] = useState<string | null>(null)
const [serverList, setServerList] = useState<Server[]>([])
const [issueOpen, setIssueOpen] = 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<string, Server>()
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])
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => setIssueOpen(true)}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
Backend недоступен live-операции отключены.
</div>
)}
{loadError && isLive && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{loadError}
</div>
)}
{(expiring.length > 0 || expired.length > 0) && (
<div className="flex flex-col gap-2">
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
{expiring.length > 0 && <CertPartAlertExpiring expiring={expiring} />}
</div>
)}
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
{liveReady && (
<CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl}
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
acmeZoneId={acmeZoneId}
setAcmeZoneId={setAcmeZoneId}
acmeTokenDraft={acmeTokenDraft}
setAcmeTokenDraft={setAcmeTokenDraft}
acmeTokenConfigured={acmeTokenConfigured}
acmeSaveBusy={acmeSaveBusy}
onTest={() => {
void handleTestAcme()
}}
onSave={() => {
void handleSaveAcmeSettings()
}}
/>
)}
<Card>
<CertPartTableToolbar
search={search}
setSearch={setSearch}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
filteredCount={filtered.length}
/>
<CertPartTableHeader />
{prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">Загрузка сертификатов…</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Сертификаты не найдены</p>
</div>
) : (
filtered.map((cert) => (
<CertRow
key={cert.id}
cert={cert}
server={serverById.get(cert.serverId)}
expanded={expandedIds.has(cert.id)}
onToggle={() => toggleExpand(cert.id)}
/>
))
)}
</Card>
<CertPartReference />
</div>
</div>
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>Выпуск сертификата</SheetTitle>
<SheetDescription>
Let&apos;s Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
</SheetDescription>
</SheetHeader>
<CertPartIssueForm
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
<SheetFooter className="mt-4">
<SheetClose render={<Button variant="outline" disabled={issueBusy} />}>
Отмена
</SheetClose>
<Button
disabled={!liveReady || issueBusy}
onClick={() => {
void handleIssue()
}}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}