1139 lines
37 KiB
TypeScript
1139 lines
37 KiB
TypeScript
"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",
|
||
},
|
||
}
|
||
|
||
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 Field({
|
||
label,
|
||
hint,
|
||
required,
|
||
children,
|
||
}: {
|
||
label: string
|
||
hint?: string
|
||
required?: boolean
|
||
children: ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm font-medium">
|
||
{label}
|
||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||
</label>
|
||
{children}
|
||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={cn(
|
||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
||
checked ? "bg-primary" : "bg-input",
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0",
|
||
)}
|
||
/>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function SectionTitle({ children }: { children: ReactNode }) {
|
||
return (
|
||
<div className="flex items-center gap-2 py-0.5">
|
||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||
<div className="flex-1 h-px bg-border" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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'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,
|
||
}: {
|
||
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-5">
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Основные</SectionTitle>
|
||
<Field 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>
|
||
</Field>
|
||
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||
<Input
|
||
className="font-mono"
|
||
value={issueCertName}
|
||
onChange={(e) => setIssueCertName(e.target.value)}
|
||
placeholder="router-le"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Домены</SectionTitle>
|
||
<Field label="Common Name" required hint="Основное имя в сертификате">
|
||
<Input
|
||
className="font-mono"
|
||
value={issueCommonName}
|
||
onChange={(e) => setIssueCommonName(e.target.value)}
|
||
placeholder="vpn.example.com"
|
||
/>
|
||
</Field>
|
||
<Field 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"
|
||
/>
|
||
</Field>
|
||
<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>
|
||
|
||
<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>
|
||
<Toggle 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>
|
||
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||
</div>
|
||
</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}
|
||
/>
|
||
<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={setIssueOpen}>
|
||
<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>
|
||
|
||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||
<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}
|
||
/>
|
||
</div>
|
||
|
||
<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>
|
||
<Button
|
||
className="flex-1"
|
||
disabled={!liveReady || issueBusy}
|
||
onClick={() => {
|
||
void handleIssue()
|
||
}}
|
||
>
|
||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
)
|
||
}
|