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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user