diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts index c9f8430..e69972c 100644 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -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 = { 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, }; }); diff --git a/apps/web/package.json b/apps/web/package.json index 01fabff..d3ef571 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts index 36cdd2f..68195ba 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -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 { diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx index 58a5117..cc581bc 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx @@ -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 = { + '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 = {}; + 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 (
- {/* Primary action button */} - +
+ - {/* Error state */} - {error && ( -

{error}

- )} + {result && result.certs.length > 1 && ( + + )} +
+ + {error &&

{error}

} - {/* Empty state — shown when no result and no error */} {!result && !error && (

{t('emptyState.split')}

@@ -62,19 +96,23 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
)} - {/* Per-cert download list */} {result && (
    {result.certs.map((cert) => (
  • -
    - +
    + + {t(`certRole.${cert.certRole ?? 'end-entity'}`)} + + {cert.subject.cn || cert.filename} - + {cert.validity.notAfter}
    @@ -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')} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 48894c5..81dc538 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -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.", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index ae6fa75..dd33f4e 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a00c66d..cdab648 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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