Skip to content

Commit c7814bb

Browse files
authored
Merge pull request #3287 from faytranevozter/feat/enhance-certificate-view
feat(certificates): enhance certificate view
2 parents 6dfa762 + c0d6eac commit c7814bb

2 files changed

Lines changed: 307 additions & 8 deletions

File tree

apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
1+
import {
2+
AlertCircle,
3+
ChevronDown,
4+
ChevronRight,
5+
Link,
6+
Loader2,
7+
ShieldCheck,
8+
Trash2,
9+
} from "lucide-react";
10+
import { useState } from "react";
211
import { toast } from "sonner";
312
import { AlertBlock } from "@/components/shared/alert-block";
413
import { DialogAction } from "@/components/shared/dialog-action";
@@ -12,13 +21,19 @@ import {
1221
} from "@/components/ui/card";
1322
import { api } from "@/utils/api";
1423
import { AddCertificate } from "./add-certificate";
15-
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
24+
import {
25+
extractLeafCommonName,
26+
getCertificateChainExpirationDetails,
27+
getCertificateChainInfo,
28+
getExpirationStatus,
29+
} from "./utils";
1630

1731
export const ShowCertificates = () => {
1832
const { mutateAsync, isPending: isRemoving } =
1933
api.certificates.remove.useMutation();
2034
const { data, isPending, refetch } = api.certificates.all.useQuery();
2135
const { data: permissions } = api.user.getPermissions.useQuery();
36+
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
2237

2338
return (
2439
<div className="w-full">
@@ -66,6 +81,30 @@ export const ShowCertificates = () => {
6681
const chainInfo = getCertificateChainInfo(
6782
certificate.certificateData,
6883
);
84+
const commonName = extractLeafCommonName(
85+
certificate.certificateData,
86+
);
87+
const chainDetails = chainInfo.isChain
88+
? getCertificateChainExpirationDetails(
89+
certificate.certificateData,
90+
)
91+
: null;
92+
const isExpanded = expandedChains.has(
93+
certificate.certificateId,
94+
);
95+
96+
const toggleChain = () => {
97+
setExpandedChains((prev) => {
98+
const next = new Set(prev);
99+
if (next.has(certificate.certificateId)) {
100+
next.delete(certificate.certificateId);
101+
} else {
102+
next.add(certificate.certificateId);
103+
}
104+
return next;
105+
});
106+
};
107+
69108
return (
70109
<div
71110
key={certificate.certificateId}
@@ -77,12 +116,52 @@ export const ShowCertificates = () => {
77116
<span className="text-sm font-medium">
78117
{index + 1}. {certificate.name}
79118
</span>
119+
{commonName && (
120+
<span className="text-xs text-muted-foreground">
121+
CN: {commonName}
122+
</span>
123+
)}
80124
{chainInfo.isChain && (
81-
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
82-
<Link className="size-3 text-muted-foreground" />
83-
<span className="text-xs text-muted-foreground">
84-
Chain ({chainInfo.count})
85-
</span>
125+
<div className="flex flex-col gap-1.5 mt-1">
126+
<button
127+
type="button"
128+
onClick={toggleChain}
129+
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 w-fit hover:bg-muted transition-colors"
130+
>
131+
{isExpanded ? (
132+
<ChevronDown className="size-3 text-muted-foreground" />
133+
) : (
134+
<ChevronRight className="size-3 text-muted-foreground" />
135+
)}
136+
<Link className="size-3 text-muted-foreground" />
137+
<span className="text-xs text-muted-foreground">
138+
Chain ({chainInfo.count} certificates)
139+
</span>
140+
</button>
141+
{isExpanded && (
142+
<div className="flex flex-col gap-3 pl-2 border-l-2 border-muted">
143+
{chainDetails?.map((cert) => (
144+
<div
145+
key={cert.index}
146+
className="flex flex-col gap-1 p-2 rounded-md bg-muted/30"
147+
>
148+
<span className="text-xs font-medium text-muted-foreground">
149+
{cert.label}
150+
</span>
151+
{cert.commonName && (
152+
<span className="text-xs text-muted-foreground/80">
153+
CN: {cert.commonName}
154+
</span>
155+
)}
156+
<span
157+
className={`text-xs ${cert.className}`}
158+
>
159+
{cert.message}
160+
</span>
161+
</div>
162+
))}
163+
</div>
164+
)}
86165
</div>
87166
)}
88167
<div

apps/dokploy/components/dashboard/settings/certificates/utils.ts

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
// @ts-nocheck
22

3+
// Split certificate chain into individual certificates
4+
export const splitCertificateChain = (certData: string): string[] => {
5+
const certRegex =
6+
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
7+
const matches = certData.match(certRegex);
8+
return matches || [];
9+
};
10+
311
export const extractExpirationDate = (certData: string): Date | null => {
412
try {
513
// Decode PEM base64 to DER binary
@@ -94,8 +102,156 @@ export const extractExpirationDate = (certData: string): Date | null => {
94102
}
95103
};
96104

105+
export const extractCommonName = (certData: string): string | null => {
106+
try {
107+
// Decode PEM base64 to DER binary
108+
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
109+
const binStr = atob(b64);
110+
const der = new Uint8Array(binStr.length);
111+
for (let i = 0; i < binStr.length; i++) {
112+
der[i] = binStr.charCodeAt(i);
113+
}
114+
115+
let offset = 0;
116+
117+
// Helper: read ASN.1 length field
118+
function readLength(pos: number): { length: number; offset: number } {
119+
// biome-ignore lint/style/noParameterAssign: <explanation>
120+
let len = der[pos++];
121+
if (len & 0x80) {
122+
const bytes = len & 0x7f;
123+
len = 0;
124+
for (let i = 0; i < bytes; i++) {
125+
// biome-ignore lint/style/noParameterAssign: <explanation>
126+
len = (len << 8) + der[pos++];
127+
}
128+
}
129+
return { length: len, offset: pos };
130+
}
131+
132+
// Helper: skip a field
133+
function skipField(pos: number): number {
134+
// biome-ignore lint/style/noParameterAssign: <explanation>
135+
pos++;
136+
const fieldLen = readLength(pos);
137+
return fieldLen.offset + fieldLen.length;
138+
}
139+
140+
// Skip the outer certificate sequence
141+
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
142+
({ offset } = readLength(offset));
143+
144+
// Skip tbsCertificate sequence
145+
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
146+
({ offset } = readLength(offset));
147+
148+
// Check for optional version field (context-specific tag [0])
149+
if (der[offset] === 0xa0) {
150+
offset++;
151+
const versionLen = readLength(offset);
152+
offset = versionLen.offset + versionLen.length;
153+
}
154+
155+
// Skip serialNumber
156+
offset = skipField(offset);
157+
158+
// Skip signature
159+
offset = skipField(offset);
160+
161+
// Skip issuer
162+
offset = skipField(offset);
163+
164+
// Skip validity
165+
offset = skipField(offset);
166+
167+
// Subject sequence - where we find the CN
168+
if (der[offset++] !== 0x30) throw new Error("Expected subject sequence");
169+
const subjectLen = readLength(offset);
170+
const subjectEnd = subjectLen.offset + subjectLen.length;
171+
offset = subjectLen.offset;
172+
173+
// Parse subject RDNs looking for CN (OID 2.5.4.3)
174+
while (offset < subjectEnd) {
175+
if (der[offset++] !== 0x31) continue; // SET
176+
const setLen = readLength(offset);
177+
offset = setLen.offset;
178+
179+
if (der[offset++] !== 0x30) continue; // SEQUENCE
180+
const seqLen = readLength(offset);
181+
offset = seqLen.offset;
182+
183+
if (der[offset++] !== 0x06) continue; // OID
184+
const oidLen = readLength(offset);
185+
offset = oidLen.offset;
186+
187+
// Check if OID is 2.5.4.3 (commonName)
188+
const oid = Array.from(der.slice(offset, offset + oidLen.length));
189+
offset += oidLen.length;
190+
191+
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
192+
if (
193+
oid.length === 3 &&
194+
oid[0] === 0x55 &&
195+
oid[1] === 0x04 &&
196+
oid[2] === 0x03
197+
) {
198+
// Next should be the string value
199+
const strType = der[offset++];
200+
const strLen = readLength(offset);
201+
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
202+
return new TextDecoder().decode(cnBytes);
203+
}
204+
}
205+
206+
return null;
207+
} catch (error) {
208+
console.error("Error parsing certificate CN:", error);
209+
return null;
210+
}
211+
};
212+
213+
// Extract the Common Name from the first (leaf) certificate in a chain
214+
export const extractLeafCommonName = (certData: string): string | null => {
215+
const certs = splitCertificateChain(certData);
216+
if (certs.length === 0) return null;
217+
return extractCommonName(certs[0]);
218+
};
219+
220+
// Extract expiration dates from all certificates in a chain
221+
export const extractAllExpirationDates = (
222+
certData: string,
223+
): Array<{
224+
cert: string;
225+
index: number;
226+
expirationDate: Date | null;
227+
commonName: string | null;
228+
}> => {
229+
const certs = splitCertificateChain(certData);
230+
return certs.map((cert, index) => ({
231+
cert,
232+
index,
233+
expirationDate: extractExpirationDate(cert),
234+
commonName: extractCommonName(cert),
235+
}));
236+
};
237+
238+
// Get the earliest expiration date from a certificate chain
239+
export const getEarliestExpirationDate = (certData: string): Date | null => {
240+
const expirationDates = extractAllExpirationDates(certData);
241+
const validDates = expirationDates
242+
.filter((item) => item.expirationDate !== null)
243+
.map((item) => item.expirationDate as Date);
244+
245+
if (validDates.length === 0) return null;
246+
247+
return new Date(Math.min(...validDates.map((date) => date.getTime())));
248+
};
249+
97250
export const getExpirationStatus = (certData: string) => {
98-
const expirationDate = extractExpirationDate(certData);
251+
const chainInfo = getCertificateChainInfo(certData);
252+
const expirationDate = chainInfo.isChain
253+
? getEarliestExpirationDate(certData)
254+
: extractExpirationDate(certData);
99255

100256
if (!expirationDate)
101257
return {
@@ -153,3 +309,67 @@ export const getCertificateChainInfo = (certData: string) => {
153309
count: 1,
154310
};
155311
};
312+
313+
// Get detailed expiration information for all certificates in a chain
314+
export const getCertificateChainExpirationDetails = (certData: string) => {
315+
const allExpirations = extractAllExpirationDates(certData);
316+
const now = new Date();
317+
318+
return allExpirations.map(({ index, expirationDate, commonName }) => {
319+
if (!expirationDate) {
320+
return {
321+
index,
322+
label: `Certificate ${index + 1}`,
323+
commonName,
324+
status: "unknown" as const,
325+
className: "text-muted-foreground",
326+
message: "Could not determine expiration",
327+
expirationDate: null,
328+
};
329+
}
330+
331+
const daysUntilExpiration = Math.ceil(
332+
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
333+
);
334+
335+
let status: "expired" | "warning" | "valid";
336+
let className: string;
337+
let message: string;
338+
339+
if (daysUntilExpiration < 0) {
340+
status = "expired";
341+
className = "text-red-500";
342+
message = `Expired on ${expirationDate.toLocaleDateString([], {
343+
year: "numeric",
344+
month: "long",
345+
day: "numeric",
346+
})}`;
347+
} else if (daysUntilExpiration <= 30) {
348+
status = "warning";
349+
className = "text-yellow-500";
350+
message = `Expires in ${daysUntilExpiration} days`;
351+
} else {
352+
status = "valid";
353+
className = "text-muted-foreground";
354+
message = `Expires ${expirationDate.toLocaleDateString([], {
355+
year: "numeric",
356+
month: "long",
357+
day: "numeric",
358+
})}`;
359+
}
360+
361+
return {
362+
index,
363+
label:
364+
index === 0
365+
? `Certificate ${index + 1} (Leaf)`
366+
: `Certificate ${index + 1}`,
367+
commonName,
368+
status,
369+
className,
370+
message,
371+
expirationDate,
372+
daysUntilExpiration,
373+
};
374+
});
375+
};

0 commit comments

Comments
 (0)