Files
MikrotikManager/app/(main)/certificates/page.tsx
T
Denozordec a1a9124f3d
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
fix(config): добавить новые зависимости и обновить конфигурацию компонентов
2026-06-30 21:30:40 +07:00

1173 lines
40 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 { 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: <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",
},
}
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 (
<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={cn(
CERT_TABLE_GRID_CLASS,
"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 min-w-0">
<span className="shrink-0">{cfg.icon}</span>
<span className="font-medium text-sm truncate" title={cert.name}>
{cert.name}
</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
{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 min-w-0" title={cert.issuedBy}>
{cert.issuedBy}
</p>
<div className="min-w-0">
<CertPartDays cert={cert} pct={pct} />
</div>
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
{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 shrink-0 justify-self-end",
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>
<form
className="grid gap-3 md:grid-cols-2"
autoComplete="off"
onSubmit={(event) => event.preventDefault()}
>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">ACME directory URL</label>
<Input
autoComplete="off"
name="acme-directory-url"
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
autoComplete="off"
name="cloudflare-zone-id"
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"
autoComplete="new-password"
name="cloudflare-api-token"
value={acmeTokenDraft}
onChange={(e) => setAcmeTokenDraft(e.target.value)}
placeholder={
acmeTokenConfigured
? "Токен сохранён — введите новый для замены"
: "API token с правом DNS"
}
/>
</div>
</form>
<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={cn(
CERT_TABLE_GRID_CLASS,
"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,
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 (
<div className="flex flex-col gap-5">
{(showAll || step === 1) && (
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="" disabled>
Выбрать сервер…
</option>
{serverList.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.site})
</option>
))}
</select>
</FormField>
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input
className="font-mono"
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
</FormField>
</div>
)}
{(showAll || step === 2) && (
<div className="flex flex-col gap-4">
<SectionTitle>Домены</SectionTitle>
<FormField label="Common Name" required hint="Основное имя в сертификате">
<Input
className="font-mono"
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
</FormField>
<FormField label="SAN" hint="По одному имени в строке">
<textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Let&apos;s Encrypt · DNS-01 (Cloudflare)</p>
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
</div>
</div>
)}
{(showAll || step === 3) && (
<div className="flex flex-col gap-4">
<SectionTitle>Импорт на RouterOS</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div>
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p>
</div>
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
</div>
</div>
)}
{(showAll || step === 4) && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium mb-2">Проверьте параметры</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
<li>Имя: {issueCertName || "—"}</li>
<li>CN: {issueCommonName || "—"}</li>
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
</ul>
</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 [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<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 variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); 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}
/>
<div className="overflow-x-auto">
<div className="min-w-[48rem]">
<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)}
/>
))
)}
</div>
</div>
</Card>
<CertPartReference />
</div>
</div>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle>
<SheetDescription>
Let&apos;s Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
</SheetDescription>
</SheetHeader>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
{[
{ step: 1, title: "Основные" },
{ step: 2, title: "Домены" },
{ step: 3, title: "Импорт" },
{ step: 4, title: "Проверка" },
].map(({ step, title }, i, arr) => (
<StepperItem key={step} step={step}>
<StepperTrigger>
<StepperIndicator>{step}</StepperIndicator>
<StepperTitle className="sr-only">{title}</StepperTitle>
</StepperTrigger>
{i < arr.length - 1 && <StepperSeparator />}
</StepperItem>
))}
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
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}
/>
</StepperContent>
))}
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
Отмена
</SheetClose>
{issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
Назад
</Button>
)}
{issueStep < 4 ? (
<Button
className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => { void handleIssue() }}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт сертификата"
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
accept=".pem,.crt,.cer,.p12,.pfx"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</div>
)
}