feat(cert-manager): cert role badges + ZIP download in split view
- API: detectCertRole() classifies certs as root/intermediate/end-entity via basicConstraints.cA + self-signed check (subject.hash === issuer.hash) - API: SplitEntry gains certRole field; filenames now reflect role (root-ca.pem, intermediate-1.pem, cert.pem) - Web: SplitTab shows colour-coded role badge per cert (red=Root-CA, amber=Zwischen-CA, blue=Zertifikat) - Web: "Alle als ZIP herunterladen" button via fflate (client-side) - i18n: add certRole labels + downloadZip action key (de + en) - i18n: add missing accentColor* and deleteAvatar* keys (de + en) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ export interface CertDetails {
|
||||
// SplitResponse — the structured result returned by splitCerts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CertRole = 'root' | 'intermediate' | 'end-entity';
|
||||
|
||||
export interface SplitEntry {
|
||||
index: number;
|
||||
filename: string;
|
||||
@@ -29,6 +31,7 @@ export interface SplitEntry {
|
||||
content: string;
|
||||
subject: { cn: string };
|
||||
validity: { notAfter: string };
|
||||
certRole: CertRole;
|
||||
}
|
||||
|
||||
export interface SplitResponse {
|
||||
@@ -140,6 +143,13 @@ export class CertManagerService {
|
||||
return blocks.map((b) => forge.pki.certificateFromPem(b));
|
||||
}
|
||||
|
||||
private detectCertRole(cert: forge.pki.Certificate): CertRole {
|
||||
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
|
||||
if (!bc?.cA) return 'end-entity';
|
||||
// Self-signed = subject hash matches issuer hash → Root CA
|
||||
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCert — CERT-01 + CERT-05 (read half)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -364,19 +374,34 @@ export class CertManagerService {
|
||||
throw new BadRequestException('Failed to split certificates: invalid format or corrupted file');
|
||||
}
|
||||
|
||||
// ── Determine cert roles ───────────────────────────────────────────────
|
||||
const roles: CertRole[] = certs.map((cert) => this.detectCertRole(cert));
|
||||
|
||||
// Build counters for filename disambiguation
|
||||
const roleCounters: Record<CertRole, number> = { root: 0, intermediate: 0, 'end-entity': 0 };
|
||||
const roleFilename = (role: CertRole): string => {
|
||||
roleCounters[role]++;
|
||||
const n = roleCounters[role];
|
||||
if (role === 'root') return n === 1 ? 'root-ca.pem' : `root-ca-${n}.pem`;
|
||||
if (role === 'intermediate') return `intermediate-${n}.pem`;
|
||||
return n === 1 ? 'cert.pem' : `cert-${n}.pem`;
|
||||
};
|
||||
|
||||
// ── Build SplitResponse ────────────────────────────────────────────────
|
||||
const certEntries: SplitEntry[] = certs.map((cert, index) => {
|
||||
const pemStr = forge.pki.certificateToPem(cert);
|
||||
const content = Buffer.from(pemStr, 'utf-8').toString('base64');
|
||||
const cn: string = cert.subject.getField('CN')?.value ?? '';
|
||||
const notAfter: string = cert.validity.notAfter.toISOString();
|
||||
const certRole = roles[index];
|
||||
|
||||
return {
|
||||
index,
|
||||
filename: `cert-${index + 1}.pem`,
|
||||
filename: roleFilename(certRole),
|
||||
content,
|
||||
subject: { cn },
|
||||
validity: { notAfter },
|
||||
certRole,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uiw/react-md-editor": "4.1.1",
|
||||
"fflate": "^0.8.3",
|
||||
"jose": "^6.2.3",
|
||||
"next": "^15.3.0",
|
||||
"next-intl": "^4.13.0",
|
||||
|
||||
@@ -5,6 +5,8 @@ export const API_URL =
|
||||
// SplitResponse — mirrors CertManagerService.SplitResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CertRole = 'root' | 'intermediate' | 'end-entity';
|
||||
|
||||
export interface SplitEntry {
|
||||
index: number;
|
||||
filename: string;
|
||||
@@ -12,6 +14,7 @@ export interface SplitEntry {
|
||||
content: string;
|
||||
subject: { cn: string };
|
||||
validity: { notAfter: string };
|
||||
certRole: CertRole;
|
||||
}
|
||||
|
||||
export interface SplitResponse {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type SplitResponse, splitCertsAction, downloadBase64 } from '../actions';
|
||||
import { zipSync } from 'fflate';
|
||||
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
||||
|
||||
interface SplitTabProps {
|
||||
file: File | null;
|
||||
@@ -10,6 +11,35 @@ interface SplitTabProps {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const ROLE_STYLES: Record<CertRole, string> = {
|
||||
'root': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
'intermediate': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
};
|
||||
|
||||
function downloadAllAsZip(certs: SplitResponse['certs'], t: (k: string) => string) {
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (const cert of certs) {
|
||||
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
|
||||
// Deduplicate filenames (fflate overwrites silently otherwise)
|
||||
let name = cert.filename;
|
||||
let n = 1;
|
||||
while (name in files) {
|
||||
const base = cert.filename.replace(/\.pem$/, '');
|
||||
name = `${base}-${++n}.pem`;
|
||||
}
|
||||
files[name] = bytes;
|
||||
}
|
||||
const zipped = zipSync(files);
|
||||
const blob = new Blob([zipped], { type: 'application/zip' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'certificates.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -36,25 +66,29 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
|
||||
}
|
||||
}
|
||||
|
||||
const canSplit = Boolean(file);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Primary action button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSplit}
|
||||
disabled={loading || !canSplit}
|
||||
disabled={loading || !file}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.split')}
|
||||
</button>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{result && result.certs.length > 1 && (
|
||||
<button
|
||||
onClick={() => downloadAllAsZip(result.certs, t)}
|
||||
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
|
||||
>
|
||||
{t('actions.downloadZip')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Empty state — shown when no result and no error */}
|
||||
{!result && !error && (
|
||||
<div className="text-center py-8 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p>
|
||||
@@ -62,19 +96,23 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-cert download list */}
|
||||
{result && (
|
||||
<ul className="space-y-2">
|
||||
{result.certs.map((cert) => (
|
||||
<li
|
||||
key={cert.index}
|
||||
className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm"
|
||||
className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium break-all">
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`shrink-0 inline-block rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.certRole ?? 'end-entity']}`}
|
||||
>
|
||||
{t(`certRole.${cert.certRole ?? 'end-entity'}`)}
|
||||
</span>
|
||||
<span className="font-medium truncate">
|
||||
{cert.subject.cn || cert.filename}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
<span className="text-muted-foreground text-xs shrink-0">
|
||||
{cert.validity.notAfter}
|
||||
</span>
|
||||
</div>
|
||||
@@ -82,7 +120,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
|
||||
onClick={() =>
|
||||
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
|
||||
}
|
||||
className="ml-3 shrink-0 bg-secondary text-secondary-foreground border border-border px-3 py-1 rounded text-xs font-medium hover:opacity-80 transition-opacity"
|
||||
className="shrink-0 border border-border px-3 py-1 rounded text-xs font-medium hover:bg-background transition-colors"
|
||||
>
|
||||
{t('actions.download')}
|
||||
</button>
|
||||
|
||||
@@ -125,7 +125,16 @@
|
||||
"uploadSuccess": "Profilbild wurde erfolgreich aktualisiert.",
|
||||
"uploadError": "Hochladen fehlgeschlagen. Bitte pruefen Sie das Dateiformat und die Dateigröße.",
|
||||
"ldapManagedNotice": "Ihr Passwort wird ueber das Verzeichnis (LDAP) verwaltet. Eine Aenderung ist hier nicht moeglich.",
|
||||
"passwordSectionTitle": "Passwort aendern"
|
||||
"passwordSectionTitle": "Passwort aendern",
|
||||
"accentColorLabel": "Akzentfarbe",
|
||||
"accentColorHelp": "Passt die Hauptfarbe der Oberflaeche an.",
|
||||
"accentColorReset": "Zuruecksetzen",
|
||||
"accentColorSave": "Farbe speichern",
|
||||
"accentColorSuccess": "Akzentfarbe erfolgreich gespeichert.",
|
||||
"accentColorError": "Speichern fehlgeschlagen.",
|
||||
"deleteAvatarCta": "Bild loeschen",
|
||||
"deleteAvatarSuccess": "Profilbild erfolgreich geloescht.",
|
||||
"deleteAvatarError": "Loeschen fehlgeschlagen."
|
||||
},
|
||||
"sourceDeleteConfirm": "Moechten Sie diese Kalenderquelle wirklich loeschen?",
|
||||
"sourceDeleteCta": "Quelle loeschen",
|
||||
@@ -463,8 +472,14 @@
|
||||
"merge": "Zusammenfuehren",
|
||||
"convert": "Konvertieren",
|
||||
"download": "Herunterladen",
|
||||
"downloadZip": "Alle als ZIP herunterladen",
|
||||
"processing": "Wird verarbeitet..."
|
||||
},
|
||||
"certRole": {
|
||||
"root": "Root-CA",
|
||||
"intermediate": "Zwischen-CA",
|
||||
"end-entity": "Zertifikat"
|
||||
},
|
||||
"emptyState": {
|
||||
"inspect": "Kein Zertifikat geladen.",
|
||||
"inspectBody": "Lade eine Datei hoch oder fuege PEM-Text ein.",
|
||||
|
||||
@@ -125,7 +125,16 @@
|
||||
"uploadSuccess": "Profile picture updated successfully.",
|
||||
"uploadError": "Upload failed. Please check the file format and size.",
|
||||
"ldapManagedNotice": "Your password is managed via the directory (LDAP) and cannot be changed here.",
|
||||
"passwordSectionTitle": "Change Password"
|
||||
"passwordSectionTitle": "Change Password",
|
||||
"accentColorLabel": "Accent Color",
|
||||
"accentColorHelp": "Customizes the primary color of the interface.",
|
||||
"accentColorReset": "Reset",
|
||||
"accentColorSave": "Save color",
|
||||
"accentColorSuccess": "Accent color saved successfully.",
|
||||
"accentColorError": "Failed to save color.",
|
||||
"deleteAvatarCta": "Delete picture",
|
||||
"deleteAvatarSuccess": "Profile picture deleted successfully.",
|
||||
"deleteAvatarError": "Failed to delete picture."
|
||||
},
|
||||
"sourceDeleteConfirm": "Are you sure you want to delete this calendar source?",
|
||||
"sourceDeleteCta": "Delete source",
|
||||
@@ -463,8 +472,14 @@
|
||||
"merge": "Merge",
|
||||
"convert": "Convert",
|
||||
"download": "Download",
|
||||
"downloadZip": "Download all as ZIP",
|
||||
"processing": "Processing..."
|
||||
},
|
||||
"certRole": {
|
||||
"root": "Root CA",
|
||||
"intermediate": "Intermediate CA",
|
||||
"end-entity": "Certificate"
|
||||
},
|
||||
"emptyState": {
|
||||
"inspect": "No certificate loaded.",
|
||||
"inspectBody": "Upload a file or paste PEM text.",
|
||||
|
||||
Generated
+8
@@ -169,6 +169,9 @@ importers:
|
||||
'@uiw/react-md-editor':
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
jose:
|
||||
specifier: ^6.2.3
|
||||
version: 6.2.3
|
||||
@@ -3163,6 +3166,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-type@21.3.4:
|
||||
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -8525,6 +8531,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-type@21.3.4:
|
||||
dependencies:
|
||||
'@tokenizer/inflate': 0.4.1
|
||||
|
||||
Reference in New Issue
Block a user