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 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-06 22:36:46 +01:00
parent d6658f09fd
commit 6ecd195073
9 changed files with 1238 additions and 723 deletions
@@ -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 <Chip label="No PEM" size="small" />;
}
if (status === "expired") {
return <Chip label={`Expired ${formatDate(validTo)}`} color="error" size="small" />;
}
if (status === "expiring_soon") {
return <Chip label={`Expires ${formatDate(validTo)}`} color="warning" size="small" />;
}
return <Chip label={`Expires ${formatDate(validTo)}`} color="success" size="small" />;
}
export default function CertificatesClient({
acmeHosts,
importedCerts,
managedCerts,
caCertificates,
acmePagination,
}: Props) {
const [activeTab, setActiveTab] = useState<TabId>("acme");
const [searchAcme, setSearchAcme] = useState("");
const [searchImported, setSearchImported] = useState("");
const [searchCa, setSearchCa] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
export default function CertificatesClient({ acmeHosts, importedCerts, managedCerts, caCertificates, acmePagination }: Props) {
const [createCaOpen, setCreateCaOpen] = useState(false);
const [editCaCert, setEditCaCert] = useState<CaCertificateView | null>(null);
const [deleteCaCert, setDeleteCaCert] = useState<CaCertificateView | null>(null);
const [issueCaCert, setIssueCaCert] = useState<CaCertificateView | null>(null);
const [manageIssuedCaCert, setManageIssuedCaCert] = useState<CaCertificateView | null>(null);
const acmeColumns = [
{
id: 'name',
label: 'Proxy Host',
render: (r: AcmeHost) => <Typography fontWeight={600}>{r.name}</Typography>,
},
{
id: 'domains',
label: 'Domains',
render: (r: AcmeHost) => (
<Typography variant="body2" color="text.secondary">
{r.domains.join(', ')}
</Typography>
),
},
{
id: 'issuer',
label: 'Issuer',
render: (r: AcmeHost) => (
<Typography variant="body2" color="text.secondary">
{r.certIssuer ?? '—'}
</Typography>
),
},
{
id: 'expiry',
label: 'Expiry',
render: (r: AcmeHost) => <ExpiryChip validTo={r.certValidTo} status={r.certExpiryStatus} />,
},
{
id: 'status',
label: 'Status',
render: (r: AcmeHost) => (
<Chip
label={r.enabled ? 'Active' : 'Disabled'}
color={r.enabled ? 'success' : 'default'}
size="small"
/>
),
},
// 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 (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={3} sx={{ width: "100%" }}>
{/* Page header */}
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
@@ -124,456 +72,71 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
</Typography>
</Stack>
{/* ACME Certificates */}
<Stack spacing={2}>
<Typography variant="h6" fontWeight={600}>
ACME Certificates
</Typography>
<Typography variant="body2" color="text.secondary">
These proxy hosts use Caddy&apos;s automatic ACME certificate management (Let&apos;s Encrypt / ZeroSSL).
No manual configuration required.
</Typography>
<DataTable
columns={acmeColumns}
data={acmeHosts}
keyField="id"
emptyMessage="No proxy hosts using automatic ACME certificates"
pagination={acmePagination}
/>
</Stack>
{/* Status summary bar */}
<StatusSummaryBar
expired={expired}
expiringSoon={expiringSoon}
healthy={healthy}
filter={statusFilter}
onFilter={setStatusFilter}
/>
<Divider />
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tab
label={`ACME (${acmePagination.total})`}
value="acme"
/>
<Tab
label={`Imported (${importedCerts.length})`}
value="imported"
/>
<Tab
label={`CA / mTLS (${caCertificates.length})`}
value="ca"
/>
</Tabs>
</Box>
{/* Imported Certificates */}
{importedCerts.length > 0 && (
<Stack spacing={2}>
<Typography variant="h6" fontWeight={600}>
Imported Certificates
</Typography>
{/* Per-tab search */}
<TextField
placeholder={
activeTab === "acme"
? "Search by host name or domain…"
: activeTab === "imported"
? "Search by name or domain…"
: "Search by name…"
}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ maxWidth: 400 }}
inputProps={{ "aria-label": "search" }}
/>
<Stack spacing={2}>
{importedCerts.map((cert) => (
<Card
key={cert.id}
elevation={0}
sx={{ border: "1px solid rgba(148, 163, 184, 0.14)" }}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{/* Header row */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 2,
}}
>
<Box>
<Typography variant="h6" fontWeight={600}>
{cert.name}
</Typography>
{cert.issuer && (
<Typography variant="body2" color="text.secondary">
{cert.issuer}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ flexShrink: 0 }}>
<ExpiryChip validTo={cert.validTo} status={cert.expiryStatus} />
<Chip label="Custom" color="secondary" size="small" />
</Stack>
</Box>
{/* Domains row */}
<Typography variant="body2" color="text.secondary">
{cert.domains.join(", ")}
</Typography>
{/* UsedBy row */}
{cert.usedBy.length > 0 && (
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Typography variant="body2" color="text.secondary" sx={{ mr: 0.5 }}>
Used by:
</Typography>
{cert.usedBy.map((host) => (
<Chip key={host.id} label={host.name} size="small" variant="outlined" />
))}
</Stack>
)}
{/* Edit/Delete accordion */}
<Accordion elevation={0} disableGutters sx={{ bgcolor: "transparent" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit / Delete</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack
component="form"
action={(formData) => updateCertificateAction(cert.id, formData)}
spacing={2}
>
<TextField name="name" label="Name" defaultValue={cert.name} fullWidth />
<TextField
name="domain_names"
label="Domains (one per line)"
defaultValue={cert.domains.join("\n")}
multiline
minRows={3}
fullWidth
helperText="Domains covered by this certificate"
/>
<input type="hidden" name="type" value="imported" />
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
multiline
minRows={6}
fullWidth
helperText="Full chain recommended (cert + intermediates)"
/>
<TextField
name="private_key_pem"
label="Private Key PEM"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
multiline
minRows={6}
fullWidth
helperText="Keep this secure! Never share your private key"
type="password"
/>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Update Certificate
</Button>
<Button
type="submit"
formAction={deleteCertificateAction.bind(null, cert.id)}
variant="outlined"
color="error"
>
Delete
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Stack>
</Stack>
)}
{/* Import Custom Certificate */}
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Import Custom Certificate
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
<strong>When to import certificates:</strong>
</Typography>
<Typography variant="body2" component="ul" sx={{ mt: 1, mb: 0, pl: 2 }}>
<li>Using an internal Certificate Authority (CA)</li>
<li>Wildcard certificates from your DNS provider</li>
<li>Pre-existing certificates you want to reuse</li>
<li>Special compliance or security requirements</li>
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
<strong>Otherwise:</strong> Just create a proxy host with your domain - Caddy will handle everything automatically!
</Typography>
</Alert>
<Card>
<CardContent>
<Stack component="form" action={createCertificateAction} spacing={2}>
<TextField
name="name"
label="Certificate Name"
placeholder="Internal CA Certificate"
required
fullWidth
helperText="Descriptive name to identify this certificate"
/>
<TextField
name="domain_names"
label="Domains (one per line)"
placeholder="*.example.com&#10;example.com"
multiline
minRows={3}
required
fullWidth
helperText="List all domains/subdomains covered by this certificate"
/>
<input type="hidden" name="type" value="imported" />
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
multiline
minRows={8}
required
fullWidth
helperText="Paste the full certificate chain (certificate + intermediate certificates)"
/>
<TextField
name="private_key_pem"
label="Private Key PEM"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
multiline
minRows={8}
required
fullWidth
helperText="Private key for this certificate. Stored securely."
type="password"
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" size="large">
Import Certificate
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
{/* Legacy Managed (conditional, collapsed) */}
{managedCerts.length > 0 && (
<Accordion elevation={0} disableGutters sx={{ border: "1px solid rgba(148, 163, 184, 0.14)", borderRadius: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography fontWeight={600}>Legacy Managed Certificates</Typography>
<Chip label="Legacy" color="warning" size="small" />
</Stack>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<Alert severity="warning">
<Typography variant="body2">
<strong>Legacy &quot;Managed&quot; certificates detected:</strong> These entries are redundant since Caddy automatically manages HTTPS.
Consider deleting them unless you need to explicitly track certificate usage.
</Typography>
</Alert>
<Stack spacing={2}>
{managedCerts.map((cert) => (
<Card key={cert.id} sx={{ bgcolor: "action.hover" }}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<Box>
<Typography variant="h6" fontWeight={600}>
{cert.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{cert.domain_names.join(", ")}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Chip label="Managed by Caddy" color="info" size="small" />
<Chip label="Auto-Renew" color="success" size="small" variant="outlined" />
</Stack>
</Box>
<Accordion elevation={0} disableGutters sx={{ bgcolor: "transparent" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit / Delete</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack
component="form"
action={(formData) => updateCertificateAction(cert.id, formData)}
spacing={2}
>
<TextField name="name" label="Name" defaultValue={cert.name} fullWidth />
<TextField
name="domain_names"
label="Domains (one per line)"
defaultValue={cert.domain_names.join("\n")}
multiline
minRows={3}
fullWidth
helperText="These domains will be automatically managed by Caddy's ACME"
/>
<input type="hidden" name="type" value="managed" />
<input type="hidden" name="auto_renew" value="on" />
<input type="hidden" name="auto_renew_present" value="1" />
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button type="submit" variant="contained">
Save
</Button>
<Button
type="submit"
formAction={deleteCertificateAction.bind(null, cert.id)}
variant="outlined"
color="error"
>
Delete
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Stack>
</Stack>
</AccordionDetails>
</Accordion>
)}
{/* Client CA Certificates */}
<Accordion defaultExpanded={caCertificates.length > 0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Typography fontWeight={700}>Client CA Certificates</Typography>
<Chip label={caCertificates.length} size="small" />
</Stack>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
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.
</Typography>
<Button
startIcon={<AddIcon />}
variant="outlined"
size="small"
onClick={() => setCreateCaOpen(true)}
>
Add CA Certificate
</Button>
</Stack>
{caCertificates.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No client CA certificates configured.
</Typography>
) : (
<DataTable
columns={[
{
id: "name",
label: "Name",
render: (ca: CaCertificateView) => <Typography fontWeight={600}>{ca.name}</Typography>,
},
{
id: "created_at",
label: "Added",
render: (ca: CaCertificateView) => (
<Typography variant="body2" color="text.secondary">
{new Date(ca.created_at).toLocaleDateString()}
</Typography>
),
},
{
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 (
<Typography variant="body2" color="text.secondary">
None
</Typography>
);
}
return (
<Chip
label={`${activeCount}/${ca.issuedCerts.length} active`}
size="small"
color={activeCount > 0 ? "success" : "default"}
variant="outlined"
/>
);
},
},
{
id: "actions",
label: "",
render: (ca: CaCertificateView) => (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
{ca.has_private_key && (
<Tooltip title="Issue Client Certificate">
<Button size="small" variant="outlined" onClick={() => setIssueCaCert(ca)}>
Issue Cert
</Button>
</Tooltip>
)}
<Button
size="small"
variant="outlined"
onClick={() => setManageIssuedCaCert(ca)}
disabled={ca.issuedCerts.length === 0}
>
Issued
</Button>
<Tooltip title="Edit">
<IconButton size="small" onClick={() => setEditCaCert(ca)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" color="error" onClick={() => setDeleteCaCert(ca)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
),
},
]}
data={caCertificates}
keyField="id"
emptyMessage="No CA certificates found"
/>
)}
</Stack>
</AccordionDetails>
</Accordion>
{/* CA Cert Dialogs */}
<CreateCaCertDialog open={createCaOpen} onClose={() => setCreateCaOpen(false)} />
{editCaCert && (
<EditCaCertDialog
open={!!editCaCert}
cert={editCaCert}
onClose={() => setEditCaCert(null)}
{/* Tab panels */}
{activeTab === "acme" && (
<AcmeTab
acmeHosts={acmeHosts}
acmePagination={acmePagination}
search={searchAcme}
statusFilter={statusFilter}
/>
)}
{deleteCaCert && (
<DeleteCaCertDialog
open={!!deleteCaCert}
cert={deleteCaCert}
onClose={() => setDeleteCaCert(null)}
{activeTab === "imported" && (
<ImportedTab
importedCerts={importedCerts}
managedCerts={managedCerts}
search={searchImported}
statusFilter={statusFilter}
/>
)}
{issueCaCert && (
<IssueClientCertDialog
open={!!issueCaCert}
cert={issueCaCert}
onClose={() => setIssueCaCert(null)}
/>
)}
{manageIssuedCaCert && (
<ManageIssuedClientCertsDialog
open={!!manageIssuedCaCert}
cert={manageIssuedCaCert}
issuedCerts={manageIssuedCaCert.issuedCerts}
onClose={() => setManageIssuedCaCert(null)}
{activeTab === "ca" && (
<CaTab
caCertificates={caCertificates}
search={searchCa}
statusFilter={statusFilter}
/>
)}
</Stack>
@@ -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) => <Typography fontWeight={600}>{r.name}</Typography>,
},
{
id: "domains",
label: "Domains",
render: (r: AcmeHost) => (
<Typography variant="body2" color="text.secondary">
{r.domains.join(", ")}
</Typography>
),
},
{
id: "issuer",
label: "Issuer",
render: (r: AcmeHost) => (
<Typography variant="body2" color="text.secondary">
{r.certIssuer ?? "—"}
</Typography>
),
},
{
id: "expiry",
label: "Expiry",
render: (r: AcmeHost) => <RelativeTime validTo={r.certValidTo} status={r.certExpiryStatus} />,
},
{
id: "status",
label: "Status",
render: (r: AcmeHost) => (
<Chip
label={r.enabled ? "Active" : "Disabled"}
color={r.enabled ? "success" : "default"}
size="small"
/>
),
},
];
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 (
<DataTable
columns={columns}
data={filtered}
keyField="id"
emptyMessage="No ACME certificates match"
pagination={pagination}
/>
);
}
@@ -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<HTMLFormElement>(null);
const importRef = useRef<HTMLFormElement>(null);
const editRef = useRef<HTMLFormElement>(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 (
<Drawer
anchor="right"
open={open}
onClose={handleClose}
PaperProps={{ sx: { width: { xs: "100%", sm: 480 }, p: 3 } }}
>
<Stack spacing={3} height="100%">
{/* Header */}
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6" fontWeight={600}>
{isEdit ? "Edit CA Certificate" : "Add CA Certificate"}
</Typography>
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</Stack>
{/* Content */}
{isEdit ? (
/* Edit form */
<Box
component="form"
ref={editRef}
onSubmit={handleEdit}
sx={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2 }}
>
<TextField
name="name"
label="Name"
required
fullWidth
defaultValue={cert.name}
autoFocus
/>
<TextField
name="certificate_pem"
label="Certificate PEM"
required
fullWidth
multiline
minRows={8}
defaultValue={cert.certificate_pem}
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
helperText="PEM-encoded X.509 CA certificate"
/>
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: "auto", pt: 2 }}>
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</Stack>
</Box>
) : (
/* Add: Generate / Import tabs */
<Stack spacing={2} sx={{ flex: 1, overflowY: "auto" }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)}>
<Tab value="generate" label="Generate" />
<Tab value="import" label="Import PEM" />
</Tabs>
{tab === "generate" && (
<Box
component="form"
ref={generateRef}
onSubmit={handleGenerate}
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
>
<TextField
name="name"
label="Name"
required
fullWidth
autoFocus
placeholder="My Client CA"
helperText="Display name in this UI"
/>
<TextField
name="common_name"
label="Common Name (CN)"
fullWidth
placeholder="My Client CA"
helperText="CN field in the certificate. Defaults to the name above if left blank."
/>
<TextField
name="validity_days"
label="Validity"
type="number"
fullWidth
defaultValue={3650}
inputProps={{ min: 1, max: 3650 }}
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
/>
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: "auto", pt: 2 }}>
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Generating..." : "Generate CA Certificate"}
</Button>
</Stack>
</Box>
)}
{tab === "import" && (
<Box
component="form"
ref={importRef}
onSubmit={handleImport}
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
>
<TextField
name="name"
label="Name"
required
fullWidth
autoFocus
placeholder="My Client CA"
/>
<TextField
name="certificate_pem"
label="Certificate PEM"
required
fullWidth
multiline
minRows={8}
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
/>
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: "auto", pt: 2 }}>
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Adding..." : "Add CA Certificate"}
</Button>
</Stack>
</Box>
)}
</Stack>
)}
</Stack>
</Drawer>
);
}
@@ -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 (
<Box sx={{ p: 2, bgcolor: "action.hover" }}>
<Stack spacing={1.5}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle2" fontWeight={600}>
Issued Client Certificates ({ca.issuedCerts.length})
</Typography>
<Stack direction="row" spacing={1}>
{ca.has_private_key && (
<Button size="small" variant="outlined" onClick={() => setIssueCaOpen(true)}>
Issue Cert
</Button>
)}
{ca.issuedCerts.length > 0 && (
<Button size="small" variant="outlined" onClick={() => setManageOpen(true)}>
Manage
</Button>
)}
</Stack>
</Stack>
{ca.issuedCerts.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No issued client certificates tracked for this CA.
</Typography>
) : (
<>
{ca.issuedCerts.slice(0, 5).map((issued) => {
const expired = new Date(issued.valid_to).getTime() < Date.now();
return (
<Stack
key={issued.id}
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>
{issued.common_name}
</Typography>
<Chip
label={issued.revoked_at ? "Revoked" : expired ? "Expired" : "Active"}
color={issued.revoked_at ? "default" : expired ? "error" : "success"}
size="small"
/>
</Stack>
);
})}
{ca.issuedCerts.length > 5 && (
<Typography variant="body2" color="text.secondary">
+{ca.issuedCerts.length - 5} more click &quot;Manage&quot; to view all
</Typography>
)}
</>
)}
</Stack>
<ManageIssuedClientCertsDialog
open={manageOpen}
cert={ca}
issuedCerts={ca.issuedCerts}
onClose={() => setManageOpen(false)}
/>
<IssueClientCertDialog
open={issueCaOpen}
cert={ca}
onClose={() => setIssueCaOpen(false)}
/>
</Box>
);
}
function CaActionsMenu({
ca,
onEdit,
onDelete,
}: {
ca: CaCertificateView;
onEdit: () => void;
onDelete: () => void;
}) {
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
const [issuedOpen, setIssuedOpen] = useState(false);
return (
<>
<IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}>
<MoreVertIcon fontSize="small" />
</IconButton>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{ca.has_private_key && (
<MenuItem onClick={() => { setAnchor(null); setIssuedOpen(true); }}>
Issue Client Cert
</MenuItem>
)}
<MenuItem onClick={() => { setAnchor(null); onEdit(); }}>Edit</MenuItem>
<MenuItem sx={{ color: "error.main" }} onClick={() => { setAnchor(null); onDelete(); }}>
Delete
</MenuItem>
</Menu>
<IssueClientCertDialog open={issuedOpen} cert={ca} onClose={() => setIssuedOpen(false)} />
</>
);
}
export function CaTab({ caCertificates, search, statusFilter }: Props) {
const [drawerCert, setDrawerCert] = useState<CaCertificateView | null | false>(false);
const [deleteCert, setDeleteCert] = useState<CaCertificateView | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(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 (
<Stack spacing={2}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
startIcon={<AddIcon />}
variant="outlined"
size="small"
onClick={() => setDrawerCert(null)}
>
Add CA Certificate
</Button>
</Box>
<TableContainer component={Card} variant="outlined" sx={{ overflowX: "auto" }}>
<Table sx={{ minWidth: 600 }}>
<TableHead>
<TableRow>
<TableCell />
<TableCell>Name</TableCell>
<TableCell>Private Key</TableCell>
<TableCell>Issued Certs</TableCell>
<TableCell>Added</TableCell>
<TableCell align="right" />
</TableRow>
</TableHead>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 8 }}>
<Typography color="text.secondary">
{search || statusFilter ? "No CA certificates match" : "No CA certificates configured."}
</Typography>
</TableCell>
</TableRow>
) : (
filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
const expanded = expandedId === ca.id;
return (
<>
<TableRow key={ca.id}>
<TableCell width={40} sx={{ pr: 0 }}>
<IconButton
size="small"
onClick={() => setExpandedId(expanded ? null : ca.id)}
>
{expanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</IconButton>
</TableCell>
<TableCell>
<Typography fontWeight={600}>{ca.name}</Typography>
</TableCell>
<TableCell>
{ca.has_private_key ? (
<Chip label="Stored" size="small" color="success" variant="outlined" />
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
<TableCell>
{ca.issuedCerts.length === 0 ? (
<Typography variant="body2" color="text.secondary">None</Typography>
) : (
<Chip
label={`${activeCount}/${ca.issuedCerts.length} active`}
size="small"
color={activeCount > 0 ? "success" : "default"}
variant="outlined"
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatRelativeDate(ca.created_at)}
</Typography>
</TableCell>
<TableCell align="right">
<CaActionsMenu
ca={ca}
onEdit={() => setDrawerCert(ca)}
onDelete={() => setDeleteCert(ca)}
/>
</TableCell>
</TableRow>
<TableRow key={`${ca.id}-expand`}>
<TableCell colSpan={6} sx={{ p: 0, border: expanded ? undefined : "none" }}>
<Collapse in={expanded} unmountOnExit>
<IssuedCertsPanel ca={ca} />
</Collapse>
</TableCell>
</TableRow>
</>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<CaCertDrawer
open={drawerCert !== false}
cert={drawerCert || null}
onClose={() => setDrawerCert(false)}
/>
{deleteCert && (
<DeleteCaCertDialog
open={!!deleteCert}
cert={deleteCert}
onClose={() => setDeleteCert(null)}
/>
)}
</Stack>
);
}
@@ -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<HTMLFormElement>(null);
const certFileRef = useRef<HTMLInputElement>(null);
const keyFileRef = useRef<HTMLInputElement>(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 (
<Drawer
anchor="right"
open={open}
onClose={handleClose}
PaperProps={{ sx: { width: { xs: "100%", sm: 480 }, p: 3 } }}
>
<Stack spacing={3} height="100%">
{/* Header */}
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6" fontWeight={600}>
{isEdit ? "Edit Certificate" : "Import Certificate"}
</Typography>
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</Stack>
{/* Form */}
<Box
component="form"
ref={formRef}
onSubmit={handleSubmit}
sx={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2 }}
>
<input type="hidden" name="type" value="imported" />
<TextField
name="name"
label="Name"
defaultValue={isEdit ? cert.name : ""}
required
fullWidth
autoFocus
helperText="Descriptive name to identify this certificate"
/>
<TextField
name="domain_names"
label="Domains (one per line)"
defaultValue={isEdit ? cert.domains.join("\n") : ""}
multiline
minRows={3}
fullWidth
helperText="Domains covered by this certificate"
/>
{/* Certificate PEM */}
<Stack spacing={1}>
<TextField
name="certificate_pem"
label="Certificate PEM"
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
multiline
minRows={6}
fullWidth
value={certPem}
onChange={(e) => setCertPem(e.target.value)}
helperText="Full chain recommended (cert + intermediates)"
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
/>
<input
type="file"
ref={certFileRef}
accept=".pem,.crt,.cer,.txt"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) readFile(file, setCertPem);
}}
/>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon />}
onClick={() => certFileRef.current?.click()}
sx={{ alignSelf: "flex-start" }}
>
Load from file
</Button>
</Stack>
{/* Private Key PEM */}
<Stack spacing={1}>
<TextField
name="private_key_pem"
label="Private Key PEM"
placeholder={"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"}
multiline={showKey}
minRows={showKey ? 6 : undefined}
type={showKey ? "text" : "password"}
fullWidth
value={keyPem}
onChange={(e) => setKeyPem(e.target.value)}
helperText="Keep this secure! Never share your private key"
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip title={showKey ? "Hide" : "Show"}>
<IconButton size="small" onClick={() => setShowKey((v) => !v)} edge="end">
{showKey ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
<input
type="file"
ref={keyFileRef}
accept=".pem,.key,.txt"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) readFile(file, setKeyPem);
}}
/>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon />}
onClick={() => keyFileRef.current?.click()}
sx={{ alignSelf: "flex-start" }}
>
Load from file
</Button>
</Stack>
{/* Actions */}
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: "auto", pt: 2 }}>
<Button onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Saving..." : isEdit ? "Save Changes" : "Import Certificate"}
</Button>
</Stack>
</Box>
</Stack>
</Drawer>
);
}
@@ -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 (
<Stack direction="row" spacing={0.5} flexWrap="wrap">
{visible.map((d) => (
<Chip key={d} label={d} size="small" variant="outlined" />
))}
{rest.length > 0 && (
<Tooltip title={rest.join(", ")}>
<Chip label={`+${rest.length} more`} size="small" />
</Tooltip>
)}
</Stack>
);
}
function ActionsMenu({ cert, onEdit }: { cert: ImportedCertView; onEdit: () => void }) {
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [isPending, startTransition] = useTransition();
function handleDelete() {
startTransition(async () => {
await deleteCertificateAction(cert.id);
setAnchor(null);
});
}
return (
<>
<IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}>
<MoreVertIcon fontSize="small" />
</IconButton>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => { setAnchor(null); setConfirmDelete(false); }}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<MenuItem onClick={() => { setAnchor(null); onEdit(); }}>Edit</MenuItem>
{confirmDelete ? (
<MenuItem
sx={{ color: "error.main" }}
disabled={isPending}
onClick={handleDelete}
>
{isPending ? "Deleting..." : "Confirm Delete"}
</MenuItem>
) : (
<MenuItem sx={{ color: "error.main" }} onClick={() => setConfirmDelete(true)}>
Delete
</MenuItem>
)}
</Menu>
</>
);
}
export function ImportedTab({ importedCerts, managedCerts, search, statusFilter }: Props) {
const [drawerCert, setDrawerCert] = useState<ImportedCertView | null | false>(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) => <Typography fontWeight={600}>{c.name}</Typography>,
},
{
id: "domains",
label: "Domains",
render: (c: ImportedCertView) => <DomainsCell domains={c.domains} />,
},
{
id: "expiry",
label: "Expires",
render: (c: ImportedCertView) => <RelativeTime validTo={c.validTo} status={c.expiryStatus} />,
},
{
id: "usedBy",
label: "Used by",
render: (c: ImportedCertView) =>
c.usedBy.length === 0 ? (
<Typography variant="body2" color="text.secondary">
</Typography>
) : (
<Stack direction="row" spacing={0.5} flexWrap="wrap">
{c.usedBy.map((h) => (
<Chip key={h.id} label={h.name} size="small" variant="outlined" />
))}
</Stack>
),
},
{
id: "actions",
label: "",
align: "right" as const,
render: (c: ImportedCertView) => (
<ActionsMenu cert={c} onEdit={() => setDrawerCert(c)} />
),
},
];
return (
<Stack spacing={2}>
{/* Add button */}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
startIcon={<AddIcon />}
variant="outlined"
size="small"
onClick={() => setDrawerCert(null)}
>
Import Certificate
</Button>
</Box>
<DataTable
columns={columns}
data={filtered}
keyField="id"
emptyMessage="No imported certificates match"
/>
{/* Legacy managed certs */}
{managedCerts.length > 0 && (
<Stack spacing={1}>
<Alert severity="warning">
Legacy &quot;managed&quot; certificate entries detected. These are redundant Caddy handles
HTTPS automatically. Consider deleting them.
</Alert>
<LegacyManagedTable managedCerts={managedCerts} />
</Stack>
)}
<ImportCertDrawer
open={drawerCert !== false}
cert={drawerCert || null}
onClose={() => setDrawerCert(false)}
/>
</Stack>
);
}
function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[] }) {
const [isPending, startTransition] = useTransition();
const columns = [
{
id: "name",
label: "Name",
render: (c: ManagedCertView) => (
<Typography variant="body2" fontWeight={600}>
{c.name}
</Typography>
),
},
{
id: "domains",
label: "Domains",
render: (c: ManagedCertView) => (
<Typography variant="body2" color="text.secondary">
{c.domain_names.join(", ")}
</Typography>
),
},
{
id: "actions",
label: "",
align: "right" as const,
render: (c: ManagedCertView) => (
<Button
size="small"
variant="outlined"
color="error"
disabled={isPending}
onClick={() => startTransition(async () => { await deleteCertificateAction(c.id); })}
>
Delete
</Button>
),
},
];
return (
<DataTable
columns={columns}
data={managedCerts}
keyField="id"
emptyMessage="No legacy managed certificates"
/>
);
}
@@ -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 (
<Typography variant="body2" color="text.secondary">
</Typography>
);
}
const color =
status === "expired"
? "error.main"
: status === "expiring_soon"
? "warning.main"
: "success.main";
return (
<Tooltip title={formatFull(validTo)}>
<Typography variant="body2" sx={{ color, fontWeight: 500, cursor: "default" }}>
{formatRelative(validTo)}
</Typography>
</Tooltip>
);
}
@@ -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 (
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label={`${expired} expired`}
color="error"
variant={filter === "expired" ? "filled" : "outlined"}
onClick={() => toggle("expired")}
clickable
size="small"
/>
<Chip
label={`${expiringSoon} expiring soon`}
color="warning"
variant={filter === "expiring_soon" ? "filled" : "outlined"}
onClick={() => toggle("expiring_soon")}
clickable
size="small"
/>
<Chip
label={`${healthy} healthy`}
color="success"
variant={filter === "ok" ? "filled" : "outlined"}
onClick={() => toggle("ok")}
clickable
size="small"
/>
</Stack>
);
}
@@ -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<HTMLFormElement>(null);
const generateFormRef = useRef<HTMLFormElement>(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 (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Add Client CA Certificate</DialogTitle>
<DialogContent>
<>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab value="generate" label="Generate" />
<Tab value="import" label="Import PEM" />
</Tabs>
{tab === "generate" && (
<form ref={generateFormRef} onSubmit={handleGenerate}>
<Stack spacing={2}>
<TextField
name="name"
label="Name"
required
fullWidth
autoFocus
placeholder="My Client CA"
helperText="Display name in this UI"
/>
<TextField
name="common_name"
label="Common Name (CN)"
fullWidth
placeholder="My Client CA"
helperText="CN field in the certificate. Defaults to the name above if left blank."
/>
<TextField
name="validity_days"
label="Validity"
type="number"
fullWidth
defaultValue={3650}
inputProps={{ min: 1, max: 3650 }}
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
/>
<DialogActions sx={{ px: 0, pb: 0 }}>
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Generating..." : "Generate CA Certificate"}
</Button>
</DialogActions>
</Stack>
</form>
)}
{tab === "import" && (
<form ref={importFormRef} onSubmit={handleImport}>
<Stack spacing={2}>
<TextField
name="name"
label="Name"
required
fullWidth
autoFocus
placeholder="My Client CA"
/>
<TextField
name="certificate_pem"
label="Certificate PEM"
required
fullWidth
multiline
minRows={6}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
/>
<DialogActions sx={{ px: 0, pb: 0 }}>
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Adding..." : "Add CA Certificate"}
</Button>
</DialogActions>
</Stack>
</form>
)}
</>
</DialogContent>
</Dialog>
);
}
export function EditCaCertDialog({
open,
cert,
onClose,
}: {
open: boolean;
cert: CaCertificate;
onClose: () => void;
}) {
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const formData = new FormData(formRef.current!);
startTransition(async () => {
await updateCaCertificateAction(cert.id, formData);
onClose();
});
}
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Edit CA Certificate</DialogTitle>
<form ref={formRef} onSubmit={handleSubmit}>
<DialogContent>
<Stack spacing={2} mt={1}>
<TextField
name="name"
label="Name"
required
fullWidth
defaultValue={cert.name}
/>
<TextField
name="certificate_pem"
label="Certificate PEM"
required
fullWidth
multiline
minRows={6}
defaultValue={cert.certificate_pem}
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
helperText="PEM-encoded X.509 CA certificate"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
export function IssueClientCertDialog({
open,
cert,