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:
@@ -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's automatic ACME certificate management (Let'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----- ... -----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----- ... -----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 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----- ... -----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----- ... -----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 "Managed" 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 "Manage" 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 "managed" 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----- ... -----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,
|
||||
|
||||
Reference in New Issue
Block a user