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.
-
- }
- variant="outlined"
- size="small"
- onClick={() => setCreateCaOpen(true)}
- >
- Add CA Certificate
-
-
-
- {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)}>
+
+
+
+ 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 (
+
+
+ }
+ variant="outlined"
+ size="small"
+ onClick={() => setDrawerCert(null)}
+ >
+ Add CA Certificate
+
+
+
+
+
+
+
+
+ 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);
+ }}
+ />
+ }
+ onClick={() => certFileRef.current?.click()}
+ sx={{ alignSelf: "flex-start" }}
+ >
+ Load from file
+
+
+
+ {/* 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);
+ }}
+ />
+ }
+ onClick={() => keyFileRef.current?.click()}
+ sx={{ alignSelf: "flex-start" }}
+ >
+ Load from file
+
+
+
+ {/* 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)}>
+
+
+
+ >
+ );
+}
+
+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 */}
+
+ }
+ variant="outlined"
+ size="small"
+ onClick={() => setDrawerCert(null)}
+ >
+ Import Certificate
+
+
+
+
+
+ {/* 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 (
-
- );
-}
-
-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 (
-
- );
-}
-
export function IssueClientCertDialog({
open,
cert,