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

View File

@@ -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,