Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
818 lines
30 KiB
TypeScript
818 lines
30 KiB
TypeScript
"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 (
|
||
<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'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 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'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 [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])
|
||
|
||
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()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||
segmented={{
|
||
value: statusFilter,
|
||
onChange: setStatusFilter,
|
||
options: [
|
||
{ value: "all", label: "Все" },
|
||
{ value: "valid", label: "Действующие" },
|
||
{ value: "expired", label: "Истёкшие" },
|
||
{ value: "revoked", label: "Отозванные" },
|
||
],
|
||
}}
|
||
countLabel={`${filtered.length} сертификатов`}
|
||
/>
|
||
<CertificatesDataGrid
|
||
certificates={filtered}
|
||
serverMap={serverById}
|
||
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
|
||
/>
|
||
</DataPageCard>
|
||
|
||
<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'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>
|
||
)
|
||
}
|