mirror of
https://github.com/dokploy/dokploy.git
synced 2026-06-13 19:09:49 +00:00
Merge pull request #3287 from faytranevozter/feat/enhance-certificate-view
feat(certificates): enhance certificate view
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
@@ -12,13 +21,19 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { AddCertificate } from "./add-certificate";
|
||||
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
|
||||
import {
|
||||
extractLeafCommonName,
|
||||
getCertificateChainExpirationDetails,
|
||||
getCertificateChainInfo,
|
||||
getExpirationStatus,
|
||||
} from "./utils";
|
||||
|
||||
export const ShowCertificates = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.certificates.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -66,6 +81,30 @@ export const ShowCertificates = () => {
|
||||
const chainInfo = getCertificateChainInfo(
|
||||
certificate.certificateData,
|
||||
);
|
||||
const commonName = extractLeafCommonName(
|
||||
certificate.certificateData,
|
||||
);
|
||||
const chainDetails = chainInfo.isChain
|
||||
? getCertificateChainExpirationDetails(
|
||||
certificate.certificateData,
|
||||
)
|
||||
: null;
|
||||
const isExpanded = expandedChains.has(
|
||||
certificate.certificateId,
|
||||
);
|
||||
|
||||
const toggleChain = () => {
|
||||
setExpandedChains((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(certificate.certificateId)) {
|
||||
next.delete(certificate.certificateId);
|
||||
} else {
|
||||
next.add(certificate.certificateId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={certificate.certificateId}
|
||||
@@ -77,12 +116,52 @@ export const ShowCertificates = () => {
|
||||
<span className="text-sm font-medium">
|
||||
{index + 1}. {certificate.name}
|
||||
</span>
|
||||
{commonName && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
CN: {commonName}
|
||||
</span>
|
||||
)}
|
||||
{chainInfo.isChain && (
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
||||
<Link className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chain ({chainInfo.count})
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleChain}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 w-fit hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
<Link className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chain ({chainInfo.count} certificates)
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-3 pl-2 border-l-2 border-muted">
|
||||
{chainDetails?.map((cert) => (
|
||||
<div
|
||||
key={cert.index}
|
||||
className="flex flex-col gap-1 p-2 rounded-md bg-muted/30"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{cert.label}
|
||||
</span>
|
||||
{cert.commonName && (
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
CN: {cert.commonName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs ${cert.className}`}
|
||||
>
|
||||
{cert.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
// @ts-nocheck
|
||||
|
||||
// Split certificate chain into individual certificates
|
||||
export const splitCertificateChain = (certData: string): string[] => {
|
||||
const certRegex =
|
||||
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
|
||||
const matches = certData.match(certRegex);
|
||||
return matches || [];
|
||||
};
|
||||
|
||||
export const extractExpirationDate = (certData: string): Date | null => {
|
||||
try {
|
||||
// Decode PEM base64 to DER binary
|
||||
@@ -94,8 +102,156 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const extractCommonName = (certData: string): string | null => {
|
||||
try {
|
||||
// Decode PEM base64 to DER binary
|
||||
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
|
||||
const binStr = atob(b64);
|
||||
const der = new Uint8Array(binStr.length);
|
||||
for (let i = 0; i < binStr.length; i++) {
|
||||
der[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// Helper: read ASN.1 length field
|
||||
function readLength(pos: number): { length: number; offset: number } {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
let len = der[pos++];
|
||||
if (len & 0x80) {
|
||||
const bytes = len & 0x7f;
|
||||
len = 0;
|
||||
for (let i = 0; i < bytes; i++) {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
len = (len << 8) + der[pos++];
|
||||
}
|
||||
}
|
||||
return { length: len, offset: pos };
|
||||
}
|
||||
|
||||
// Helper: skip a field
|
||||
function skipField(pos: number): number {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
pos++;
|
||||
const fieldLen = readLength(pos);
|
||||
return fieldLen.offset + fieldLen.length;
|
||||
}
|
||||
|
||||
// Skip the outer certificate sequence
|
||||
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
|
||||
({ offset } = readLength(offset));
|
||||
|
||||
// Skip tbsCertificate sequence
|
||||
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
|
||||
({ offset } = readLength(offset));
|
||||
|
||||
// Check for optional version field (context-specific tag [0])
|
||||
if (der[offset] === 0xa0) {
|
||||
offset++;
|
||||
const versionLen = readLength(offset);
|
||||
offset = versionLen.offset + versionLen.length;
|
||||
}
|
||||
|
||||
// Skip serialNumber
|
||||
offset = skipField(offset);
|
||||
|
||||
// Skip signature
|
||||
offset = skipField(offset);
|
||||
|
||||
// Skip issuer
|
||||
offset = skipField(offset);
|
||||
|
||||
// Skip validity
|
||||
offset = skipField(offset);
|
||||
|
||||
// Subject sequence - where we find the CN
|
||||
if (der[offset++] !== 0x30) throw new Error("Expected subject sequence");
|
||||
const subjectLen = readLength(offset);
|
||||
const subjectEnd = subjectLen.offset + subjectLen.length;
|
||||
offset = subjectLen.offset;
|
||||
|
||||
// Parse subject RDNs looking for CN (OID 2.5.4.3)
|
||||
while (offset < subjectEnd) {
|
||||
if (der[offset++] !== 0x31) continue; // SET
|
||||
const setLen = readLength(offset);
|
||||
offset = setLen.offset;
|
||||
|
||||
if (der[offset++] !== 0x30) continue; // SEQUENCE
|
||||
const seqLen = readLength(offset);
|
||||
offset = seqLen.offset;
|
||||
|
||||
if (der[offset++] !== 0x06) continue; // OID
|
||||
const oidLen = readLength(offset);
|
||||
offset = oidLen.offset;
|
||||
|
||||
// Check if OID is 2.5.4.3 (commonName)
|
||||
const oid = Array.from(der.slice(offset, offset + oidLen.length));
|
||||
offset += oidLen.length;
|
||||
|
||||
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
|
||||
if (
|
||||
oid.length === 3 &&
|
||||
oid[0] === 0x55 &&
|
||||
oid[1] === 0x04 &&
|
||||
oid[2] === 0x03
|
||||
) {
|
||||
// Next should be the string value
|
||||
const strType = der[offset++];
|
||||
const strLen = readLength(offset);
|
||||
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
|
||||
return new TextDecoder().decode(cnBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error parsing certificate CN:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the Common Name from the first (leaf) certificate in a chain
|
||||
export const extractLeafCommonName = (certData: string): string | null => {
|
||||
const certs = splitCertificateChain(certData);
|
||||
if (certs.length === 0) return null;
|
||||
return extractCommonName(certs[0]);
|
||||
};
|
||||
|
||||
// Extract expiration dates from all certificates in a chain
|
||||
export const extractAllExpirationDates = (
|
||||
certData: string,
|
||||
): Array<{
|
||||
cert: string;
|
||||
index: number;
|
||||
expirationDate: Date | null;
|
||||
commonName: string | null;
|
||||
}> => {
|
||||
const certs = splitCertificateChain(certData);
|
||||
return certs.map((cert, index) => ({
|
||||
cert,
|
||||
index,
|
||||
expirationDate: extractExpirationDate(cert),
|
||||
commonName: extractCommonName(cert),
|
||||
}));
|
||||
};
|
||||
|
||||
// Get the earliest expiration date from a certificate chain
|
||||
export const getEarliestExpirationDate = (certData: string): Date | null => {
|
||||
const expirationDates = extractAllExpirationDates(certData);
|
||||
const validDates = expirationDates
|
||||
.filter((item) => item.expirationDate !== null)
|
||||
.map((item) => item.expirationDate as Date);
|
||||
|
||||
if (validDates.length === 0) return null;
|
||||
|
||||
return new Date(Math.min(...validDates.map((date) => date.getTime())));
|
||||
};
|
||||
|
||||
export const getExpirationStatus = (certData: string) => {
|
||||
const expirationDate = extractExpirationDate(certData);
|
||||
const chainInfo = getCertificateChainInfo(certData);
|
||||
const expirationDate = chainInfo.isChain
|
||||
? getEarliestExpirationDate(certData)
|
||||
: extractExpirationDate(certData);
|
||||
|
||||
if (!expirationDate)
|
||||
return {
|
||||
@@ -153,3 +309,67 @@ export const getCertificateChainInfo = (certData: string) => {
|
||||
count: 1,
|
||||
};
|
||||
};
|
||||
|
||||
// Get detailed expiration information for all certificates in a chain
|
||||
export const getCertificateChainExpirationDetails = (certData: string) => {
|
||||
const allExpirations = extractAllExpirationDates(certData);
|
||||
const now = new Date();
|
||||
|
||||
return allExpirations.map(({ index, expirationDate, commonName }) => {
|
||||
if (!expirationDate) {
|
||||
return {
|
||||
index,
|
||||
label: `Certificate ${index + 1}`,
|
||||
commonName,
|
||||
status: "unknown" as const,
|
||||
className: "text-muted-foreground",
|
||||
message: "Could not determine expiration",
|
||||
expirationDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
let status: "expired" | "warning" | "valid";
|
||||
let className: string;
|
||||
let message: string;
|
||||
|
||||
if (daysUntilExpiration < 0) {
|
||||
status = "expired";
|
||||
className = "text-red-500";
|
||||
message = `Expired on ${expirationDate.toLocaleDateString([], {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`;
|
||||
} else if (daysUntilExpiration <= 30) {
|
||||
status = "warning";
|
||||
className = "text-yellow-500";
|
||||
message = `Expires in ${daysUntilExpiration} days`;
|
||||
} else {
|
||||
status = "valid";
|
||||
className = "text-muted-foreground";
|
||||
message = `Expires ${expirationDate.toLocaleDateString([], {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`;
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
label:
|
||||
index === 0
|
||||
? `Certificate ${index + 1} (Leaf)`
|
||||
: `Certificate ${index + 1}`,
|
||||
commonName,
|
||||
status,
|
||||
className,
|
||||
message,
|
||||
expirationDate,
|
||||
daysUntilExpiration,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user