Files
caddy-proxy-manager/app/(dashboard)/certificates/components/CaTab.tsx
T
fuomag9 73c90894b1 Handle wildcard proxy hosts and stabilize test coverage
- accept wildcard proxy host domains like *.example.com with validation and normalization
- make exact hosts win over overlapping wildcards in generated routes and TLS policies
- add unit coverage for host-pattern priority and wildcard domain handling
- add a single test:all entry point and clean up lint/typecheck issues so the suite runs cleanly
- run mobile layout Playwright checks under both chromium and mobile-iphone
2026-03-14 01:03:34 +01:00

343 lines
12 KiB
TypeScript

"use client";
import {
Box,
Button,
Card,
Chip,
Collapse,
IconButton,
Menu,
MenuItem,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
useMediaQuery,
useTheme,
} 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);
const active = ca.issuedCerts.filter((c) => !c.revoked_at);
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 ({active.length} active)
</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>
{active.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No active client certificates for this CA.
</Typography>
) : (
<>
{active.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={expired ? "Expired" : "Active"}
color={expired ? "error" : "success"}
size="small"
/>
</Stack>
);
})}
{active.length > 5 && (
<Typography variant="body2" color="text.secondary">
+{active.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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
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;
});
if (isMobile) {
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>
{filtered.length === 0 ? (
<Card variant="outlined" sx={{ py: 6, textAlign: "center" }}>
<Typography color="text.secondary">
{search || statusFilter ? "No CA certificates match" : "No CA certificates configured."}
</Typography>
</Card>
) : (
<Stack spacing={1.5}>
{filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
return (
<Card key={ca.id} variant="outlined" sx={{ p: 2 }}>
<Stack spacing={0.5}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="subtitle2" fontWeight={700}>{ca.name}</Typography>
<CaActionsMenu ca={ca} onEdit={() => setDrawerCert(ca)} onDelete={() => setDeleteCert(ca)} />
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
{ca.has_private_key && <Chip label="Key stored" size="small" color="success" variant="outlined" />}
{ca.issuedCerts.length > 0 && (
<Chip label={`${activeCount}/${ca.issuedCerts.length} active`} size="small" color={activeCount > 0 ? "success" : "default"} variant="outlined" />
)}
<Typography variant="caption" color="text.secondary">{formatRelativeDate(ca.created_at)}</Typography>
</Stack>
</Stack>
</Card>
);
})}
</Stack>
)}
<CaCertDrawer open={drawerCert !== false} cert={drawerCert || null} onClose={() => setDrawerCert(false)} />
{deleteCert && <DeleteCaCertDialog open={!!deleteCert} cert={deleteCert} onClose={() => setDeleteCert(null)} />}
</Stack>
);
}
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>
);
}