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

676 lines
33 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 { Suspense, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
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 { can } from "@/lib/auth"
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
import type { CertificateView } from "@/lib/certificate-view"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { useCertificateIssue } from "@/lib/use-certificate-issue"
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-kit/accessible-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: CertificateView[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">
{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: CertificateView[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
<div>
<p className="font-medium text-warning">
{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,
complete,
totalKnown,
}: {
displayCerts: CertificateView[]
expiring: CertificateView[]
expired: CertificateView[]
complete: boolean
totalKnown: boolean
}) {
return (
<KpiStatGrid
aria-label="Сводка сертификатов"
items={[
{
id: "all",
label: "Всего",
value: totalKnown ? displayCerts.length : "—",
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "valid",
label: "Действующих",
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "expiring",
label: "Истекают",
value: complete ? expiring.length : "—",
icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
},
{
id: "expired",
label: "Истёкших",
value: complete ? expired.length : "—",
icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default",
},
]}
/>
)
}
function CertPartReference() {
return (
<OpsPanel
title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
contentClassName="px-5 py-4"
>
<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 tabIndex={0} role="region" aria-label={"Команды RouterOS: " + b.title} className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
)
}
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
aria-label="Сервер выпуска"
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"
aria-label="Имя сертификата"
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"
aria-label="Основной домен"
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"
aria-label="Дополнительные домены"
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 ariaLabel="Trust store www" 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 ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
</div>
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
</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>
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
</ul>
</div>
)}
</div>
)
}
export default function CertificatesPage() {
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
}
function CertificatesSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
return <CertificatesContent key={mode + ":" + backendUrl} />
}
function CertificatesContent() {
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true
const params = useSearchParams()
const context = readCertificatesUrl(params)
const search = context.search
const statusFilter = context.status
const selectedServerId = context.server
const urlError = context.error
function writeContext(changes: Record<string, string | null>, replace = false) {
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
}
const setSearch = (value: string) => writeContext({ q: value }, true)
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const canRead = can("mm:certificates:read")
const canWrite = can("mm:certificates:write")
const canReadServers = can("mm:servers:read")
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
const { snapshots, loading, error: loadError, serverError } = catalog
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1)
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 displayCerts = useMemo(() => {
if (!prefsHydrated) return []
if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, snapshots])
const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => {
if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
type: s.type,
enabled: s.enabled,
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
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 = catalog.reload
const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
const issueBusy = issue.busy
const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
const expiring = useMemo(
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
[scopedCerts],
)
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
const filtered = useMemo(() => {
return scopedCerts.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))
)
})
}, [scopedCerts, search, statusFilter])
async function handleRefresh() {
await loadLive()
}
async function handleIssue() {
if (issue.checkpoint) { await issue.run(); return }
if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
}
function startNewIssue() {
if (!issue.reset()) return
setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
}
return (
<>
<ServerRailLayout
items={certRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
preserveSelection
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
disabled={!isLive || loading || (!canRead && !canReadServers)}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
<UploadIcon className="size-4" />
Импорт
</Button>
<Button
size="sm"
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => {
setIssueStep(issue.checkpoint ? 4 : 1)
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
<PlusIcon className="size-4" />
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5 min-w-0">
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
{isLive && backendStatus === false && (
<div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
Backend недоступен — live-операции отключены.
</div>
)}
{loadError && isLive && (
<div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
</div>
)}
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
</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>
)}
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
<CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, CN, эмитенту…"
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: "all", label: "Все" },
{ value: "valid", label: "Действующие" },
{ value: "expired", label: "Истёкшие" },
{ value: "revoked", label: "Отозванные" },
{ value: "unknown", label: "Неизвестные" },
]}
/></div>}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/>
{urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
certificates={filtered}
serverMap={serverById}
emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
isLoading={isLive && loading && displayCerts.length === 0}
/>}
</DataPageCard>
<CertPartReference />
</div>
</ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 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>
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
</div>
<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}>
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={displayServers}
issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
setIssueServerId={setIssueServerId}
issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</fieldset>
</StepperContent>
))}
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
{issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
<SheetClose render={<Button variant="outline" className="flex-1" />}>
{issue.checkpoint ? "Закрыть" : "Отмена"}
</SheetClose>
{issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
Назад
</Button>
)}
{issueStep < 4 ? (
<Button
className="flex-1"
disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button
className="flex-1"
disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { void handleIssue() }}
>
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}