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
@@ -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 */}
<button
onClick={handleSplit}
disabled={loading || !canSplit}
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>
<div className="flex items-center gap-3">
<button
onClick={handleSplit}
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>
+16 -1
View File
@@ -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.",
+16 -1
View File
@@ -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.",