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:
2026-07-02 10:40:39 +02:00
parent 7da1c19b31
commit 819d50a222
7 changed files with 130 additions and 25 deletions
@@ -22,6 +22,8 @@ export interface CertDetails {
// SplitResponse — the structured result returned by splitCerts // SplitResponse — the structured result returned by splitCerts
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type CertRole = 'root' | 'intermediate' | 'end-entity';
export interface SplitEntry { export interface SplitEntry {
index: number; index: number;
filename: string; filename: string;
@@ -29,6 +31,7 @@ export interface SplitEntry {
content: string; content: string;
subject: { cn: string }; subject: { cn: string };
validity: { notAfter: string }; validity: { notAfter: string };
certRole: CertRole;
} }
export interface SplitResponse { export interface SplitResponse {
@@ -140,6 +143,13 @@ export class CertManagerService {
return blocks.map((b) => forge.pki.certificateFromPem(b)); 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) // 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'); 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 ──────────────────────────────────────────────── // ── Build SplitResponse ────────────────────────────────────────────────
const certEntries: SplitEntry[] = certs.map((cert, index) => { const certEntries: SplitEntry[] = certs.map((cert, index) => {
const pemStr = forge.pki.certificateToPem(cert); const pemStr = forge.pki.certificateToPem(cert);
const content = Buffer.from(pemStr, 'utf-8').toString('base64'); const content = Buffer.from(pemStr, 'utf-8').toString('base64');
const cn: string = cert.subject.getField('CN')?.value ?? ''; const cn: string = cert.subject.getField('CN')?.value ?? '';
const notAfter: string = cert.validity.notAfter.toISOString(); const notAfter: string = cert.validity.notAfter.toISOString();
const certRole = roles[index];
return { return {
index, index,
filename: `cert-${index + 1}.pem`, filename: roleFilename(certRole),
content, content,
subject: { cn }, subject: { cn },
validity: { notAfter }, validity: { notAfter },
certRole,
}; };
}); });
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@uiw/react-md-editor": "4.1.1", "@uiw/react-md-editor": "4.1.1",
"fflate": "^0.8.3",
"jose": "^6.2.3", "jose": "^6.2.3",
"next": "^15.3.0", "next": "^15.3.0",
"next-intl": "^4.13.0", "next-intl": "^4.13.0",
@@ -5,6 +5,8 @@ export const API_URL =
// SplitResponse — mirrors CertManagerService.SplitResponse // SplitResponse — mirrors CertManagerService.SplitResponse
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type CertRole = 'root' | 'intermediate' | 'end-entity';
export interface SplitEntry { export interface SplitEntry {
index: number; index: number;
filename: string; filename: string;
@@ -12,6 +14,7 @@ export interface SplitEntry {
content: string; content: string;
subject: { cn: string }; subject: { cn: string };
validity: { notAfter: string }; validity: { notAfter: string };
certRole: CertRole;
} }
export interface SplitResponse { export interface SplitResponse {
@@ -2,7 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; 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 { interface SplitTabProps {
file: File | null; file: File | null;
@@ -10,6 +11,35 @@ interface SplitTabProps {
password: string; 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) { export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) {
const t = useTranslations('certManager'); const t = useTranslations('certManager');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -36,25 +66,29 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
} }
} }
const canSplit = Boolean(file);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Primary action button */} <div className="flex items-center gap-3">
<button <button
onClick={handleSplit} 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" 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')} {loading ? t('actions.processing') : t('actions.split')}
</button> </button>
{/* Error state */} {result && result.certs.length > 1 && (
{error && ( <button
<p className="text-sm text-destructive">{error}</p> 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 && ( {!result && !error && (
<div className="text-center py-8 space-y-1"> <div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p> <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> </div>
)} )}
{/* Per-cert download list */}
{result && ( {result && (
<ul className="space-y-2"> <ul className="space-y-2">
{result.certs.map((cert) => ( {result.certs.map((cert) => (
<li <li
key={cert.index} 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"> <div className="min-w-0 flex-1 flex items-center gap-2">
<span className="font-medium break-all"> <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} {cert.subject.cn || cert.filename}
</span> </span>
<span className="ml-2 text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs shrink-0">
{cert.validity.notAfter} {cert.validity.notAfter}
</span> </span>
</div> </div>
@@ -82,7 +120,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
onClick={() => onClick={() =>
downloadBase64(cert.filename, cert.content, 'application/x-pem-file') 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')} {t('actions.download')}
</button> </button>
+16 -1
View File
@@ -125,7 +125,16 @@
"uploadSuccess": "Profilbild wurde erfolgreich aktualisiert.", "uploadSuccess": "Profilbild wurde erfolgreich aktualisiert.",
"uploadError": "Hochladen fehlgeschlagen. Bitte pruefen Sie das Dateiformat und die Dateigröße.", "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.", "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?", "sourceDeleteConfirm": "Moechten Sie diese Kalenderquelle wirklich loeschen?",
"sourceDeleteCta": "Quelle loeschen", "sourceDeleteCta": "Quelle loeschen",
@@ -463,8 +472,14 @@
"merge": "Zusammenfuehren", "merge": "Zusammenfuehren",
"convert": "Konvertieren", "convert": "Konvertieren",
"download": "Herunterladen", "download": "Herunterladen",
"downloadZip": "Alle als ZIP herunterladen",
"processing": "Wird verarbeitet..." "processing": "Wird verarbeitet..."
}, },
"certRole": {
"root": "Root-CA",
"intermediate": "Zwischen-CA",
"end-entity": "Zertifikat"
},
"emptyState": { "emptyState": {
"inspect": "Kein Zertifikat geladen.", "inspect": "Kein Zertifikat geladen.",
"inspectBody": "Lade eine Datei hoch oder fuege PEM-Text ein.", "inspectBody": "Lade eine Datei hoch oder fuege PEM-Text ein.",
+16 -1
View File
@@ -125,7 +125,16 @@
"uploadSuccess": "Profile picture updated successfully.", "uploadSuccess": "Profile picture updated successfully.",
"uploadError": "Upload failed. Please check the file format and size.", "uploadError": "Upload failed. Please check the file format and size.",
"ldapManagedNotice": "Your password is managed via the directory (LDAP) and cannot be changed here.", "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?", "sourceDeleteConfirm": "Are you sure you want to delete this calendar source?",
"sourceDeleteCta": "Delete source", "sourceDeleteCta": "Delete source",
@@ -463,8 +472,14 @@
"merge": "Merge", "merge": "Merge",
"convert": "Convert", "convert": "Convert",
"download": "Download", "download": "Download",
"downloadZip": "Download all as ZIP",
"processing": "Processing..." "processing": "Processing..."
}, },
"certRole": {
"root": "Root CA",
"intermediate": "Intermediate CA",
"end-entity": "Certificate"
},
"emptyState": { "emptyState": {
"inspect": "No certificate loaded.", "inspect": "No certificate loaded.",
"inspectBody": "Upload a file or paste PEM text.", "inspectBody": "Upload a file or paste PEM text.",
+8
View File
@@ -169,6 +169,9 @@ importers:
'@uiw/react-md-editor': '@uiw/react-md-editor':
specifier: 4.1.1 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) 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: jose:
specifier: ^6.2.3 specifier: ^6.2.3
version: 6.2.3 version: 6.2.3
@@ -3163,6 +3166,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-type@21.3.4: file-type@21.3.4:
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -8525,6 +8531,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
fflate@0.8.3: {}
file-type@21.3.4: file-type@21.3.4:
dependencies: dependencies:
'@tokenizer/inflate': 0.4.1 '@tokenizer/inflate': 0.4.1