Files
MikrotikManager/app/(main)/certificates/page.tsx
T
2026-05-02 01:17:08 +07:00

398 lines
17 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 { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { routerCertificates, servers } from "@/lib/data"
import type { RouterCertificate, CertStatus } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
SearchIcon, ShieldCheckIcon, ShieldAlertIcon, ShieldOffIcon,
BadgeCheckIcon, AlertTriangleIcon, AlertCircleIcon,
CalendarIcon, KeyRoundIcon, ServerIcon, PlusIcon,
ChevronDownIcon, ChevronRightIcon,
} from "lucide-react"
// ─── helpers ──────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<CertStatus, {
label: string; icon: React.ReactNode; badge: string; row: string
}> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function serverForCert(cert: RouterCertificate) {
return servers.find((s) => s.id === cert.serverId)
}
// ─── Certificate row ──────────────────────────────────────────────────────────
function CertRow({
cert,
expanded,
onToggle,
}: {
cert: RouterCertificate
expanded: boolean
onToggle: () => void
}) {
const srv = serverForCert(cert)
const cfg = STATUS_CONFIG[cert.status]
const pct = daysLeftBar(cert.daysLeft)
return (
<div className={cn("border-b last:border-b-0", cfg.row)}>
<div
className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggle}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggle() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name */}
<div className="min-w-0">
<div className="flex items-center gap-2">
{cfg.icon}
<span className="font-medium text-sm truncate">{cert.name}</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate">{cert.commonName}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv ? <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></> : <ServerIcon className="size-3.5" />}
</div>
{/* issued by */}
<p className="text-xs text-muted-foreground truncate">{cert.issuedBy}</p>
{/* days left */}
<div>
<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">
<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}%` }}
/>
</div>
</div>
{/* usage badges */}
<div className="flex flex-wrap gap-1">
{cert.usage.map((u) => (
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">
{u}
</span>
))}
</div>
{/* status */}
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap", cfg.badge)}>
{cfg.label}
</span>
</div>
{/* expanded detail */}
{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>
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
<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>
<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>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function CertificatesPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const expiring = useMemo(
() => routerCertificates.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[],
)
const expired = useMemo(() => routerCertificates.filter((c) => c.status === "expired"), [])
const filtered = useMemo(() => {
return routerCertificates.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))
)
})
}, [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
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Выпустить сертификат
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Alerts */}
{(expiring.length > 0 || expired.length > 0) && (
<div className="flex flex-col gap-2">
{expired.length > 0 && (
<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>
)}
{expiring.length > 0 && (
<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>
)}
</div>
)}
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerCertificates.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Действующих", value: routerCertificates.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>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
{/* search */}
<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>
{/* status filter */}
<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}
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">{filtered.length} сертификатов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / CN</span>
<span>Сервер</span>
<span>Выпущен</span>
<div className="flex items-center gap-1"><CalendarIcon className="size-3" />Срок</div>
<div className="flex items-center gap-1"><KeyRoundIcon className="size-3" />Использование</div>
<span>Статус</span>
</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}
expanded={expandedIds.has(cert.id)}
onToggle={() => toggleExpand(cert.id)}
/>
))
)}
</Card>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /certificate команды управления
</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: "Выпустить сертификат",
lines: [
"/certificate add \\",
" name=router-cert \\",
" common-name=router.example.com \\",
" subject-alt-name=\\",
" IP:10.0.0.1 \\",
" key-size=2048 days-valid=365",
"/certificate sign router-cert \\",
" ca=my-ca",
],
},
{
title: "Статус и экспорт",
lines: [
"# Список:",
"/certificate print",
"",
"# Экспорт (PKCS12):",
"/certificate export-certificate \\",
" router-cert \\",
" export-passphrase=secret",
"",
"# Импорт:",
"/certificate import \\",
" file-name=cert.crt",
],
},
].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>
</div>
</div>
</div>
)
}