mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02: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 { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -12,13 +21,19 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AddCertificate } from "./add-certificate";
|
import { AddCertificate } from "./add-certificate";
|
||||||
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
|
import {
|
||||||
|
extractLeafCommonName,
|
||||||
|
getCertificateChainExpirationDetails,
|
||||||
|
getCertificateChainInfo,
|
||||||
|
getExpirationStatus,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
export const ShowCertificates = () => {
|
export const ShowCertificates = () => {
|
||||||
const { mutateAsync, isPending: isRemoving } =
|
const { mutateAsync, isPending: isRemoving } =
|
||||||
api.certificates.remove.useMutation();
|
api.certificates.remove.useMutation();
|
||||||
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -66,6 +81,30 @@ export const ShowCertificates = () => {
|
|||||||
const chainInfo = getCertificateChainInfo(
|
const chainInfo = getCertificateChainInfo(
|
||||||
certificate.certificateData,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={certificate.certificateId}
|
key={certificate.certificateId}
|
||||||
@@ -77,12 +116,52 @@ export const ShowCertificates = () => {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{index + 1}. {certificate.name}
|
{index + 1}. {certificate.name}
|
||||||
</span>
|
</span>
|
||||||
|
{commonName && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
CN: {commonName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{chainInfo.isChain && (
|
{chainInfo.isChain && (
|
||||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
<div className="flex flex-col gap-1.5 mt-1">
|
||||||
<Link className="size-3 text-muted-foreground" />
|
<button
|
||||||
<span className="text-xs text-muted-foreground">
|
type="button"
|
||||||
Chain ({chainInfo.count})
|
onClick={toggleChain}
|
||||||
</span>
|
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>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
// @ts-nocheck
|
// @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 => {
|
export const extractExpirationDate = (certData: string): Date | null => {
|
||||||
try {
|
try {
|
||||||
// Decode PEM base64 to DER binary
|
// 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) => {
|
export const getExpirationStatus = (certData: string) => {
|
||||||
const expirationDate = extractExpirationDate(certData);
|
const chainInfo = getCertificateChainInfo(certData);
|
||||||
|
const expirationDate = chainInfo.isChain
|
||||||
|
? getEarliestExpirationDate(certData)
|
||||||
|
: extractExpirationDate(certData);
|
||||||
|
|
||||||
if (!expirationDate)
|
if (!expirationDate)
|
||||||
return {
|
return {
|
||||||
@@ -153,3 +309,67 @@ export const getCertificateChainInfo = (certData: string) => {
|
|||||||
count: 1,
|
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