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
|
// 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Generated
+8
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user