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
// ---------------------------------------------------------------------------
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,
};
});