From 6ecd1950738ff8fc258d991eefda362e17cb9596 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:36:46 +0100 Subject: [PATCH] redesign certificates page: tabs, drawers, relative expiry, status bar - Split ACME / Imported / CA-mTLS into tabs with count badges - Add clickable status summary bar (expired / expiring soon / healthy) - Per-tab search filter by name and domain - Replace accordion cards with DataTable for imported certs - Slide-in Drawers (480 px) for add/edit imported and CA certs - File upload + show/hide toggle for private key in ImportCertDrawer - CaCertDrawer: Generate / Import PEM tabs for add, simple form for edit - CA tab: expandable rows showing issued client certs inline - RelativeTime component: "in 45 days" / "EXPIRED 3 days ago" with date tooltip - Remove CreateCaCertDialog and EditCaCertDialog (replaced by CaCertDrawer) Co-Authored-By: Claude Sonnet 4.6 --- .../certificates/CertificatesClient.tsx | 643 +++--------------- .../certificates/components/AcmeTab.tsx | 85 +++ .../certificates/components/CaCertDrawer.tsx | 211 ++++++ .../certificates/components/CaTab.tsx | 293 ++++++++ .../components/ImportCertDrawer.tsx | 206 ++++++ .../certificates/components/ImportedTab.tsx | 237 +++++++ .../certificates/components/RelativeTime.tsx | 57 ++ .../components/StatusSummaryBar.tsx | 46 ++ .../ca-certificates/CaCertDialogs.tsx | 183 ----- 9 files changed, 1238 insertions(+), 723 deletions(-) create mode 100644 app/(dashboard)/certificates/components/AcmeTab.tsx create mode 100644 app/(dashboard)/certificates/components/CaCertDrawer.tsx create mode 100644 app/(dashboard)/certificates/components/CaTab.tsx create mode 100644 app/(dashboard)/certificates/components/ImportCertDrawer.tsx create mode 100644 app/(dashboard)/certificates/components/ImportedTab.tsx create mode 100644 app/(dashboard)/certificates/components/RelativeTime.tsx create mode 100644 app/(dashboard)/certificates/components/StatusSummaryBar.tsx diff --git a/app/(dashboard)/certificates/CertificatesClient.tsx b/app/(dashboard)/certificates/CertificatesClient.tsx index 4c684374..6b37e669 100644 --- a/app/(dashboard)/certificates/CertificatesClient.tsx +++ b/app/(dashboard)/certificates/CertificatesClient.tsx @@ -1,35 +1,14 @@ "use client"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Alert, - Box, - Button, - Card, - CardContent, - Chip, - Divider, - IconButton, - Stack, - TextField, - Tooltip, - Typography, -} from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { DataTable } from "@/src/components/ui/DataTable"; -import { - createCertificateAction, - deleteCertificateAction, - updateCertificateAction, -} from "./actions"; -import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page"; +import { Box, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; import { useState } from "react"; -import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog, ManageIssuedClientCertsDialog } from "@/src/components/ca-certificates/CaCertDialogs"; +import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page"; +import { StatusSummaryBar } from "./components/StatusSummaryBar"; +import { AcmeTab } from "./components/AcmeTab"; +import { ImportedTab } from "./components/ImportedTab"; +import { CaTab } from "./components/CaTab"; + +type TabId = "acme" | "imported" | "ca"; type Props = { acmeHosts: AcmeHost[]; @@ -39,80 +18,49 @@ type Props = { acmePagination: { total: number; page: number; perPage: number }; }; -function formatDate(iso: string): string { - const d = new Date(iso); - return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`; +function countExpiry(statuses: (CertExpiryStatus | null)[]) { + let expired = 0; + let expiringSoon = 0; + let healthy = 0; + for (const s of statuses) { + if (s === "expired") expired++; + else if (s === "expiring_soon") expiringSoon++; + else if (s === "ok") healthy++; + } + return { expired, expiringSoon, healthy }; } -function ExpiryChip({ - validTo, - status, -}: { - validTo: string | null; - status: CertExpiryStatus | null; -}) { - if (status === null || validTo === null) { - return ; - } - if (status === "expired") { - return ; - } - if (status === "expiring_soon") { - return ; - } - return ; -} +export default function CertificatesClient({ + acmeHosts, + importedCerts, + managedCerts, + caCertificates, + acmePagination, +}: Props) { + const [activeTab, setActiveTab] = useState("acme"); + const [searchAcme, setSearchAcme] = useState(""); + const [searchImported, setSearchImported] = useState(""); + const [searchCa, setSearchCa] = useState(""); + const [statusFilter, setStatusFilter] = useState(null); -export default function CertificatesClient({ acmeHosts, importedCerts, managedCerts, caCertificates, acmePagination }: Props) { - const [createCaOpen, setCreateCaOpen] = useState(false); - const [editCaCert, setEditCaCert] = useState(null); - const [deleteCaCert, setDeleteCaCert] = useState(null); - const [issueCaCert, setIssueCaCert] = useState(null); - const [manageIssuedCaCert, setManageIssuedCaCert] = useState(null); - const acmeColumns = [ - { - id: 'name', - label: 'Proxy Host', - render: (r: AcmeHost) => {r.name}, - }, - { - id: 'domains', - label: 'Domains', - render: (r: AcmeHost) => ( - - {r.domains.join(', ')} - - ), - }, - { - id: 'issuer', - label: 'Issuer', - render: (r: AcmeHost) => ( - - {r.certIssuer ?? '—'} - - ), - }, - { - id: 'expiry', - label: 'Expiry', - render: (r: AcmeHost) => , - }, - { - id: 'status', - label: 'Status', - render: (r: AcmeHost) => ( - - ), - }, + // Aggregate expiry counts across all cert types + const allStatuses: (CertExpiryStatus | null)[] = [ + ...acmeHosts.map((h) => h.certExpiryStatus), + ...importedCerts.map((c) => c.expiryStatus), ]; + const { expired, expiringSoon, healthy } = countExpiry(allStatuses); + + const search = activeTab === "acme" ? searchAcme : activeTab === "imported" ? searchImported : searchCa; + const setSearch = + activeTab === "acme" ? setSearchAcme : activeTab === "imported" ? setSearchImported : setSearchCa; + + function handleTabChange(_: React.SyntheticEvent, value: TabId) { + setActiveTab(value); + setStatusFilter(null); + } return ( - + {/* Page header */} @@ -124,456 +72,71 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe - {/* ACME Certificates */} - - - ACME Certificates - - - These proxy hosts use Caddy's automatic ACME certificate management (Let's Encrypt / ZeroSSL). - No manual configuration required. - - - + {/* Status summary bar */} + - + {/* Tabs */} + + + + + + + - {/* Imported Certificates */} - {importedCerts.length > 0 && ( - - - Imported Certificates - + {/* Per-tab search */} + setSearch(e.target.value)} + size="small" + sx={{ maxWidth: 400 }} + inputProps={{ "aria-label": "search" }} + /> - - {importedCerts.map((cert) => ( - - - {/* Header row */} - - - - {cert.name} - - {cert.issuer && ( - - {cert.issuer} - - )} - - - - - - - - {/* Domains row */} - - {cert.domains.join(", ")} - - - {/* UsedBy row */} - {cert.usedBy.length > 0 && ( - - - Used by: - - {cert.usedBy.map((host) => ( - - ))} - - )} - - {/* Edit/Delete accordion */} - - } sx={{ px: 0 }}> - Edit / Delete - - - updateCertificateAction(cert.id, formData)} - spacing={2} - > - - - - - - - - - - - - - - - - - ))} - - - )} - - {/* Import Custom Certificate */} - - - Import Custom Certificate - - - - - When to import certificates: - - -
  • Using an internal Certificate Authority (CA)
  • -
  • Wildcard certificates from your DNS provider
  • -
  • Pre-existing certificates you want to reuse
  • -
  • Special compliance or security requirements
  • -
    - - Otherwise: Just create a proxy host with your domain - Caddy will handle everything automatically! - -
    - - - - - - - - - - - - - - - - - - - - -
    - - {/* Legacy Managed (conditional, collapsed) */} - {managedCerts.length > 0 && ( - - }> - - Legacy Managed Certificates - - - - - - - - Legacy "Managed" certificates detected: These entries are redundant since Caddy automatically manages HTTPS. - Consider deleting them unless you need to explicitly track certificate usage. - - - - - {managedCerts.map((cert) => ( - - - - - - {cert.name} - - - {cert.domain_names.join(", ")} - - - - - - - - - - } sx={{ px: 0 }}> - Edit / Delete - - - updateCertificateAction(cert.id, formData)} - spacing={2} - > - - - - - - - - - - - - - - - - ))} - - - - - )} - - {/* Client CA Certificates */} - 0} disableGutters> - }> - - Client CA Certificates - - - - - - - - CA certificates used for mTLS. New client certificates issued from this UI are tracked here and can be - revoked individually; previously issued certificates are not backfilled. - - - - - {caCertificates.length === 0 ? ( - - No client CA certificates configured. - - ) : ( - {ca.name}, - }, - { - id: "created_at", - label: "Added", - render: (ca: CaCertificateView) => ( - - {new Date(ca.created_at).toLocaleDateString()} - - ), - }, - { - id: "issued", - label: "Issued Client Certs", - render: (ca: CaCertificateView) => { - const activeCount = ca.issuedCerts.filter((issued) => !issued.revoked_at).length; - if (ca.issuedCerts.length === 0) { - return ( - - None - - ); - } - return ( - 0 ? "success" : "default"} - variant="outlined" - /> - ); - }, - }, - { - id: "actions", - label: "", - render: (ca: CaCertificateView) => ( - - {ca.has_private_key && ( - - - - )} - - - setEditCaCert(ca)}> - - - - - setDeleteCaCert(ca)}> - - - - - ), - }, - ]} - data={caCertificates} - keyField="id" - emptyMessage="No CA certificates found" - /> - )} - - - - - {/* CA Cert Dialogs */} - setCreateCaOpen(false)} /> - {editCaCert && ( - setEditCaCert(null)} + {/* Tab panels */} + {activeTab === "acme" && ( + )} - {deleteCaCert && ( - setDeleteCaCert(null)} + {activeTab === "imported" && ( + )} - {issueCaCert && ( - setIssueCaCert(null)} - /> - )} - {manageIssuedCaCert && ( - setManageIssuedCaCert(null)} + {activeTab === "ca" && ( + )}
    diff --git a/app/(dashboard)/certificates/components/AcmeTab.tsx b/app/(dashboard)/certificates/components/AcmeTab.tsx new file mode 100644 index 00000000..d6107dbc --- /dev/null +++ b/app/(dashboard)/certificates/components/AcmeTab.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Chip, Typography } from "@mui/material"; +import { DataTable } from "@/src/components/ui/DataTable"; +import type { AcmeHost, CertExpiryStatus } from "../page"; +import { RelativeTime } from "./RelativeTime"; + +type Props = { + acmeHosts: AcmeHost[]; + acmePagination: { total: number; page: number; perPage: number }; + search: string; + statusFilter: string | null; +}; + +const columns = [ + { + id: "name", + label: "Proxy Host", + render: (r: AcmeHost) => {r.name}, + }, + { + id: "domains", + label: "Domains", + render: (r: AcmeHost) => ( + + {r.domains.join(", ")} + + ), + }, + { + id: "issuer", + label: "Issuer", + render: (r: AcmeHost) => ( + + {r.certIssuer ?? "—"} + + ), + }, + { + id: "expiry", + label: "Expiry", + render: (r: AcmeHost) => , + }, + { + id: "status", + label: "Status", + render: (r: AcmeHost) => ( + + ), + }, +]; + +export function AcmeTab({ acmeHosts, acmePagination, search, statusFilter }: Props) { + const filtered = acmeHosts.filter((h) => { + if (statusFilter && h.certExpiryStatus !== statusFilter) return false; + if (search) { + const q = search.toLowerCase(); + return ( + h.name.toLowerCase().includes(q) || + h.domains.some((d) => d.toLowerCase().includes(q)) + ); + } + return true; + }); + + // When filtering client-side, pass a fake pagination that disables server pagination display + const pagination = + search || statusFilter + ? { total: filtered.length, page: 1, perPage: filtered.length || 1 } + : acmePagination; + + return ( + + ); +} diff --git a/app/(dashboard)/certificates/components/CaCertDrawer.tsx b/app/(dashboard)/certificates/components/CaCertDrawer.tsx new file mode 100644 index 00000000..080f5c0e --- /dev/null +++ b/app/(dashboard)/certificates/components/CaCertDrawer.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { + Box, + Button, + Drawer, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useRef, useState, useTransition } from "react"; +import { + createCaCertificateAction, + generateCaCertificateAction, + updateCaCertificateAction, +} from "../ca-actions"; +import type { CaCertificateView } from "../page"; + +type Props = { + open: boolean; + cert: CaCertificateView | null; + onClose: () => void; +}; + +export function CaCertDrawer({ open, cert, onClose }: Props) { + const isEdit = cert !== null; + const [tab, setTab] = useState<"generate" | "import">("generate"); + const [isPending, startTransition] = useTransition(); + const generateRef = useRef(null); + const importRef = useRef(null); + const editRef = useRef(null); + + function handleClose() { + setTab("generate"); + onClose(); + } + + function handleGenerate(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(generateRef.current!); + startTransition(async () => { + await generateCaCertificateAction(formData); + handleClose(); + }); + } + + function handleImport(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(importRef.current!); + startTransition(async () => { + await createCaCertificateAction(formData); + handleClose(); + }); + } + + function handleEdit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(editRef.current!); + startTransition(async () => { + await updateCaCertificateAction(cert!.id, formData); + handleClose(); + }); + } + + return ( + + + {/* Header */} + + + {isEdit ? "Edit CA Certificate" : "Add CA Certificate"} + + + + + + + {/* Content */} + {isEdit ? ( + /* Edit form */ + + + + + + + + + ) : ( + /* Add: Generate / Import tabs */ + + setTab(v)}> + + + + + {tab === "generate" && ( + + + + days }} + /> + + + + + + )} + + {tab === "import" && ( + + + + + + + + + )} + + )} + + + ); +} diff --git a/app/(dashboard)/certificates/components/CaTab.tsx b/app/(dashboard)/certificates/components/CaTab.tsx new file mode 100644 index 00000000..2468dfca --- /dev/null +++ b/app/(dashboard)/certificates/components/CaTab.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { + Box, + Button, + Card, + CardContent, + Chip, + Collapse, + IconButton, + Menu, + MenuItem, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import { useState } from "react"; +import { + DeleteCaCertDialog, + IssueClientCertDialog, + ManageIssuedClientCertsDialog, +} from "@/src/components/ca-certificates/CaCertDialogs"; +import type { CaCertificateView } from "../page"; +import { CaCertDrawer } from "./CaCertDrawer"; + +type Props = { + caCertificates: CaCertificateView[]; + search: string; + statusFilter: string | null; +}; + +function formatRelativeDate(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const days = Math.floor(diff / 86400000); + if (days < 1) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days} days ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`; + const years = Math.floor(months / 12); + return `${years} year${years !== 1 ? "s" : ""} ago`; +} + +function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) { + const [issueCaOpen, setIssueCaOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); + + return ( + + + + + Issued Client Certificates ({ca.issuedCerts.length}) + + + {ca.has_private_key && ( + + )} + {ca.issuedCerts.length > 0 && ( + + )} + + + + {ca.issuedCerts.length === 0 ? ( + + No issued client certificates tracked for this CA. + + ) : ( + <> + {ca.issuedCerts.slice(0, 5).map((issued) => { + const expired = new Date(issued.valid_to).getTime() < Date.now(); + return ( + + + {issued.common_name} + + + + ); + })} + {ca.issuedCerts.length > 5 && ( + + +{ca.issuedCerts.length - 5} more — click "Manage" to view all + + )} + + )} + + + setManageOpen(false)} + /> + setIssueCaOpen(false)} + /> + + ); +} + +function CaActionsMenu({ + ca, + onEdit, + onDelete, +}: { + ca: CaCertificateView; + onEdit: () => void; + onDelete: () => void; +}) { + const [anchor, setAnchor] = useState(null); + const [issuedOpen, setIssuedOpen] = useState(false); + + return ( + <> + setAnchor(e.currentTarget)}> + + + setAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + {ca.has_private_key && ( + { setAnchor(null); setIssuedOpen(true); }}> + Issue Client Cert + + )} + { setAnchor(null); onEdit(); }}>Edit + { setAnchor(null); onDelete(); }}> + Delete + + + setIssuedOpen(false)} /> + + ); +} + +export function CaTab({ caCertificates, search, statusFilter }: Props) { + const [drawerCert, setDrawerCert] = useState(false); + const [deleteCert, setDeleteCert] = useState(null); + const [expandedId, setExpandedId] = useState(null); + + const filtered = caCertificates.filter((ca) => { + // CA certs have no expiry status so if filtering by expiry, hide them + if (statusFilter) return false; + if (search) return ca.name.toLowerCase().includes(search.toLowerCase()); + return true; + }); + + return ( + + + + + + + + + + + Name + Private Key + Issued Certs + Added + + + + + {filtered.length === 0 ? ( + + + + {search || statusFilter ? "No CA certificates match" : "No CA certificates configured."} + + + + ) : ( + filtered.map((ca) => { + const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length; + const expanded = expandedId === ca.id; + return ( + <> + + + setExpandedId(expanded ? null : ca.id)} + > + {expanded ? : } + + + + {ca.name} + + + {ca.has_private_key ? ( + + ) : ( + + )} + + + {ca.issuedCerts.length === 0 ? ( + None + ) : ( + 0 ? "success" : "default"} + variant="outlined" + /> + )} + + + + {formatRelativeDate(ca.created_at)} + + + + setDrawerCert(ca)} + onDelete={() => setDeleteCert(ca)} + /> + + + + + + + + + + + ); + }) + )} + +
    +
    + + setDrawerCert(false)} + /> + {deleteCert && ( + setDeleteCert(null)} + /> + )} +
    + ); +} diff --git a/app/(dashboard)/certificates/components/ImportCertDrawer.tsx b/app/(dashboard)/certificates/components/ImportCertDrawer.tsx new file mode 100644 index 00000000..59ee5713 --- /dev/null +++ b/app/(dashboard)/certificates/components/ImportCertDrawer.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { + Box, + Button, + Drawer, + IconButton, + InputAdornment, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { useRef, useState, useTransition } from "react"; +import { createCertificateAction, updateCertificateAction } from "../actions"; +import type { ImportedCertView } from "../page"; + +type Props = { + open: boolean; + cert: ImportedCertView | null; + onClose: () => void; +}; + +export function ImportCertDrawer({ open, cert, onClose }: Props) { + const isEdit = cert !== null; + const [isPending, startTransition] = useTransition(); + const [showKey, setShowKey] = useState(false); + const [certPem, setCertPem] = useState(""); + const [keyPem, setKeyPem] = useState(""); + const formRef = useRef(null); + const certFileRef = useRef(null); + const keyFileRef = useRef(null); + + function handleClose() { + setCertPem(""); + setKeyPem(""); + setShowKey(false); + onClose(); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(formRef.current!); + startTransition(async () => { + if (isEdit) { + await updateCertificateAction(cert.id, formData); + } else { + await createCertificateAction(formData); + } + handleClose(); + }); + } + + function readFile(file: File, setter: (v: string) => void) { + const reader = new FileReader(); + reader.onload = (e) => setter(e.target?.result as string); + reader.readAsText(file); + } + + return ( + + + {/* Header */} + + + {isEdit ? "Edit Certificate" : "Import Certificate"} + + + + + + + {/* Form */} + + + + + + + + {/* Certificate PEM */} + + setCertPem(e.target.value)} + helperText="Full chain recommended (cert + intermediates)" + inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }} + /> + { + const file = e.target.files?.[0]; + if (file) readFile(file, setCertPem); + }} + /> + + + + {/* Private Key PEM */} + + setKeyPem(e.target.value)} + helperText="Keep this secure! Never share your private key" + inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }} + InputProps={{ + endAdornment: ( + + + setShowKey((v) => !v)} edge="end"> + {showKey ? : } + + + + ), + }} + /> + { + const file = e.target.files?.[0]; + if (file) readFile(file, setKeyPem); + }} + /> + + + + {/* Actions */} + + + + + + + + ); +} diff --git a/app/(dashboard)/certificates/components/ImportedTab.tsx b/app/(dashboard)/certificates/components/ImportedTab.tsx new file mode 100644 index 00000000..ea665056 --- /dev/null +++ b/app/(dashboard)/certificates/components/ImportedTab.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { + Alert, + Box, + Button, + Chip, + IconButton, + Menu, + MenuItem, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import { useState, useTransition } from "react"; +import { DataTable } from "@/src/components/ui/DataTable"; +import { deleteCertificateAction } from "../actions"; +import type { ImportedCertView, ManagedCertView } from "../page"; +import { RelativeTime } from "./RelativeTime"; +import { ImportCertDrawer } from "./ImportCertDrawer"; + +type Props = { + importedCerts: ImportedCertView[]; + managedCerts: ManagedCertView[]; + search: string; + statusFilter: string | null; +}; + +function DomainsCell({ domains }: { domains: string[] }) { + const visible = domains.slice(0, 2); + const rest = domains.slice(2); + return ( + + {visible.map((d) => ( + + ))} + {rest.length > 0 && ( + + + + )} + + ); +} + +function ActionsMenu({ cert, onEdit }: { cert: ImportedCertView; onEdit: () => void }) { + const [anchor, setAnchor] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [isPending, startTransition] = useTransition(); + + function handleDelete() { + startTransition(async () => { + await deleteCertificateAction(cert.id); + setAnchor(null); + }); + } + + return ( + <> + setAnchor(e.currentTarget)}> + + + { setAnchor(null); setConfirmDelete(false); }} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + { setAnchor(null); onEdit(); }}>Edit + {confirmDelete ? ( + + {isPending ? "Deleting..." : "Confirm Delete"} + + ) : ( + setConfirmDelete(true)}> + Delete + + )} + + + ); +} + +export function ImportedTab({ importedCerts, managedCerts, search, statusFilter }: Props) { + const [drawerCert, setDrawerCert] = useState(false); + + const filtered = importedCerts.filter((c) => { + if (statusFilter && c.expiryStatus !== statusFilter) return false; + if (search) { + const q = search.toLowerCase(); + return ( + c.name.toLowerCase().includes(q) || + c.domains.some((d) => d.toLowerCase().includes(q)) + ); + } + return true; + }); + + const columns = [ + { + id: "name", + label: "Name", + render: (c: ImportedCertView) => {c.name}, + }, + { + id: "domains", + label: "Domains", + render: (c: ImportedCertView) => , + }, + { + id: "expiry", + label: "Expires", + render: (c: ImportedCertView) => , + }, + { + id: "usedBy", + label: "Used by", + render: (c: ImportedCertView) => + c.usedBy.length === 0 ? ( + + — + + ) : ( + + {c.usedBy.map((h) => ( + + ))} + + ), + }, + { + id: "actions", + label: "", + align: "right" as const, + render: (c: ImportedCertView) => ( + setDrawerCert(c)} /> + ), + }, + ]; + + return ( + + {/* Add button */} + + + + + + + {/* Legacy managed certs */} + {managedCerts.length > 0 && ( + + + Legacy "managed" certificate entries detected. These are redundant — Caddy handles + HTTPS automatically. Consider deleting them. + + + + )} + + setDrawerCert(false)} + /> + + ); +} + +function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[] }) { + const [isPending, startTransition] = useTransition(); + + const columns = [ + { + id: "name", + label: "Name", + render: (c: ManagedCertView) => ( + + {c.name} + + ), + }, + { + id: "domains", + label: "Domains", + render: (c: ManagedCertView) => ( + + {c.domain_names.join(", ")} + + ), + }, + { + id: "actions", + label: "", + align: "right" as const, + render: (c: ManagedCertView) => ( + + ), + }, + ]; + + return ( + + ); +} diff --git a/app/(dashboard)/certificates/components/RelativeTime.tsx b/app/(dashboard)/certificates/components/RelativeTime.tsx new file mode 100644 index 00000000..0f00cf91 --- /dev/null +++ b/app/(dashboard)/certificates/components/RelativeTime.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Tooltip, Typography } from "@mui/material"; +import type { CertExpiryStatus } from "../page"; + +function formatRelative(validTo: string): string { + const diff = new Date(validTo).getTime() - Date.now(); + const absDiff = Math.abs(diff); + const days = Math.floor(absDiff / 86400000); + const hours = Math.floor(absDiff / 3600000); + + if (diff < 0) { + if (days >= 1) return `EXPIRED ${days} day${days !== 1 ? "s" : ""} ago`; + return `EXPIRED ${hours} hour${hours !== 1 ? "s" : ""} ago`; + } + if (days >= 1) return `in ${days} day${days !== 1 ? "s" : ""}`; + return `in ${hours} hour${hours !== 1 ? "s" : ""}`; +} + +function formatFull(validTo: string): string { + return new Date(validTo).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function RelativeTime({ + validTo, + status, +}: { + validTo: string | null; + status: CertExpiryStatus | null; +}) { + if (validTo === null || status === null) { + return ( + + — + + ); + } + + const color = + status === "expired" + ? "error.main" + : status === "expiring_soon" + ? "warning.main" + : "success.main"; + + return ( + + + {formatRelative(validTo)} + + + ); +} diff --git a/app/(dashboard)/certificates/components/StatusSummaryBar.tsx b/app/(dashboard)/certificates/components/StatusSummaryBar.tsx new file mode 100644 index 00000000..802c8afb --- /dev/null +++ b/app/(dashboard)/certificates/components/StatusSummaryBar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Chip, Stack } from "@mui/material"; + +type Props = { + expired: number; + expiringSoon: number; + healthy: number; + filter: string | null; + onFilter: (f: string | null) => void; +}; + +export function StatusSummaryBar({ expired, expiringSoon, healthy, filter, onFilter }: Props) { + function toggle(key: string) { + onFilter(filter === key ? null : key); + } + + return ( + + toggle("expired")} + clickable + size="small" + /> + toggle("expiring_soon")} + clickable + size="small" + /> + toggle("ok")} + clickable + size="small" + /> + + ); +} diff --git a/src/components/ca-certificates/CaCertDialogs.tsx b/src/components/ca-certificates/CaCertDialogs.tsx index 270dfb3a..0040e3ec 100644 --- a/src/components/ca-certificates/CaCertDialogs.tsx +++ b/src/components/ca-certificates/CaCertDialogs.tsx @@ -16,8 +16,6 @@ import { InputAdornment, Stack, Switch, - Tab, - Tabs, TextField, Typography, } from "@mui/material"; @@ -27,12 +25,9 @@ import { useEffect, useRef, useState, useTransition } from "react"; import type { CaCertificate } from "@/src/lib/models/ca-certificates"; import type { IssuedClientCertificate } from "@/src/lib/models/issued-client-certificates"; import { - createCaCertificateAction, deleteCaCertificateAction, - generateCaCertificateAction, issueClientCertificateAction, revokeIssuedClientCertificateAction, - updateCaCertificateAction, } from "@/app/(dashboard)/certificates/ca-actions"; function downloadFile(filename: string, blob: Blob) { @@ -67,184 +62,6 @@ function formatFingerprint(value: string): string { return value.match(/.{1,2}/g)?.join(":") ?? value; } -export function CreateCaCertDialog({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - const [tab, setTab] = useState<"import" | "generate">("generate"); - const [isPending, startTransition] = useTransition(); - const importFormRef = useRef(null); - const generateFormRef = useRef(null); - - function handleClose() { - setTab("generate"); - onClose(); - } - - function handleImport(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(importFormRef.current!); - startTransition(async () => { - await createCaCertificateAction(formData); - handleClose(); - }); - } - - function handleGenerate(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(generateFormRef.current!); - startTransition(async () => { - await generateCaCertificateAction(formData); - handleClose(); - }); - } - - return ( - - Add Client CA Certificate - - <> - setTab(v)} sx={{ mb: 2 }}> - - - - - {tab === "generate" && ( -
    - - - - days }} - /> - - - - - -
    - )} - - {tab === "import" && ( -
    - - - - - - - - -
    - )} - -
    -
    - ); -} - -export function EditCaCertDialog({ - open, - cert, - onClose, -}: { - open: boolean; - cert: CaCertificate; - onClose: () => void; -}) { - const [isPending, startTransition] = useTransition(); - const formRef = useRef(null); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(formRef.current!); - startTransition(async () => { - await updateCaCertificateAction(cert.id, formData); - onClose(); - }); - } - - return ( - - Edit CA Certificate -
    - - - - - - - - - - -
    -
    - ); -} - export function IssueClientCertDialog({ open, cert,