merge(09-02): cert-manager web shell + i18n (resolve de/en.json conflict)

This commit is contained in:
2026-07-01 23:26:12 +02:00
12 changed files with 801 additions and 6 deletions
@@ -0,0 +1,51 @@
export const API_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Download a base64-encoded file as a browser download.
* T-09-02: password is never placed in URL, console.log, or filename.
*/
export function downloadBase64(
filename: string,
content: string,
mimeType: string,
): void {
const bytes = atob(content);
const byteArray = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
byteArray[i] = bytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
/**
* POST a FormData payload to a cert-manager endpoint.
* T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard.
* No manual Content-Type header — browser sets multipart boundary automatically.
*/
export async function postForm(
endpoint: string,
form: FormData,
): Promise<unknown> {
const response = await fetch(
`${API_URL}/modules/cert-manager/${endpoint}`,
{
method: 'POST',
body: form,
credentials: 'include',
},
);
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`${response.status} ${body}`.trim());
}
return response.json();
}
@@ -0,0 +1,101 @@
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — provide certManager namespace keys
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => {
const certManager: Record<string, string> = {
'title': 'Zertifikat-Manager',
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
'tabs.inspect': 'Analysieren',
'tabs.split': 'Aufteilen',
'tabs.merge': 'Zusammenfuehren',
'tabs.convert': 'Konvertieren',
'dropZone.placeholder': 'Datei hierher ziehen oder klicken',
'dropZone.formats': '.pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c',
'paste.placeholder': 'PEM-Inhalt einfuegen (-----BEGIN ...)',
'password.label': 'Passwort (PFX/P12)',
'or': 'oder',
'actions.inspect': 'Analysieren',
'actions.split': 'Aufteilen',
'actions.merge': 'Zusammenfuehren',
'actions.convert': 'Konvertieren',
'actions.download': 'Herunterladen',
'actions.processing': 'Wird verarbeitet...',
'emptyState.inspect': 'Kein Zertifikat geladen.',
'emptyState.inspectBody': 'Lade eine Datei hoch oder fuege PEM-Text ein.',
'emptyState.split': 'Keine Datei geladen.',
'emptyState.splitBody': 'Lade eine Fullchain- oder P7B-Datei hoch.',
'emptyState.merge': 'Keine Zertifikate ausgewaehlt.',
'emptyState.mergeBody': 'Lade mindestens zwei Dateien hoch.',
'emptyState.convert': 'Keine Datei geladen.',
'emptyState.convertBody': 'Lade eine Datei hoch und waehle ein Ausgabeformat.',
'error.generic': 'Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.',
'error.wrongPassword': 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.',
'error.unknownFormat': 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
};
if (ns === 'certManager') {
return certManager[key] ?? key;
}
return key;
},
}));
import CertManagerPage from './page';
afterEach(() => {
cleanup();
});
describe('CertManagerPage shell', () => {
it('renders the page title and description', () => {
render(<CertManagerPage />);
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
});
it('renders all four tab labels', () => {
render(<CertManagerPage />);
expect(screen.getByText('Analysieren')).toBeInTheDocument();
expect(screen.getByText('Aufteilen')).toBeInTheDocument();
expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument();
expect(screen.getByText('Konvertieren')).toBeInTheDocument();
});
it('does NOT render the password field on initial render (no PFX file selected)', () => {
render(<CertManagerPage />);
// Password field label should not be in document when no PFX is selected
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
});
it('shows Inspect tab empty state on initial render', () => {
render(<CertManagerPage />);
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
});
it('shows Split tab empty state when Split tab is active', () => {
render(<CertManagerPage />);
// Click the "Aufteilen" tab button (second one in nav)
const tabButtons = screen.getAllByText('Aufteilen');
// The tab nav button is the one that triggers tab change
fireEvent.click(tabButtons[0]);
expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument();
expect(screen.getByText('Lade eine Fullchain- oder P7B-Datei hoch.')).toBeInTheDocument();
});
it('shows Merge tab empty state when Merge tab is active', () => {
render(<CertManagerPage />);
fireEvent.click(screen.getByText('Zusammenfuehren'));
expect(screen.getByText('Keine Zertifikate ausgewaehlt.')).toBeInTheDocument();
expect(screen.getByText('Lade mindestens zwei Dateien hoch.')).toBeInTheDocument();
});
it('shows Convert tab empty state when Convert tab is active', () => {
render(<CertManagerPage />);
// "Konvertieren" appears once as tab label
fireEvent.click(screen.getByText('Konvertieren'));
expect(screen.getAllByText('Keine Datei geladen.').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument();
});
});
@@ -0,0 +1,22 @@
'use client';
import { useTranslations } from 'next-intl';
interface ConvertTabProps {
file: File | null;
pemText: string;
password: string;
}
export function ConvertTab({ file: _file, pemText: _pemText, password: _password }: ConvertTabProps) {
const t = useTranslations('certManager');
return (
<div className="space-y-4">
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
</div>
</div>
);
}
@@ -0,0 +1,99 @@
'use client';
import { useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
interface DropZoneProps {
onFile: (file: File) => void;
accept: string;
currentFile: File | null;
onClear: () => void;
}
export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps) {
const t = useTranslations('certManager');
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFile(file);
}
// Allow re-selecting the same file
e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) {
onFile(file);
}
};
return (
<div>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleFileChange}
/>
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`rounded-lg border-2 border-dashed p-8 text-center cursor-pointer transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
{currentFile ? (
<div className="flex items-center justify-center gap-3">
<span className="text-sm text-foreground">
{currentFile.name}{' '}
<span className="text-muted-foreground">
({(currentFile.size / 1024).toFixed(1)} KB)
</span>
</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClear(); }}
className="text-muted-foreground hover:text-foreground text-xs border border-border rounded px-2 py-0.5"
>
&#x2715;
</button>
</div>
) : (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('dropZone.placeholder')}
</p>
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
'use client';
import { useTranslations } from 'next-intl';
interface InspectTabProps {
file: File | null;
pemText: string;
password: string;
}
export function InspectTab({ file: _file, pemText: _pemText, password: _password }: InspectTabProps) {
const t = useTranslations('certManager');
return (
<div className="space-y-4">
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
'use client';
import { useTranslations } from 'next-intl';
interface MergeTabProps {
file: File | null;
pemText: string;
password: string;
}
export function MergeTab({ file: _file, pemText: _pemText, password: _password }: MergeTabProps) {
const t = useTranslations('certManager');
return (
<div className="space-y-4">
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
interface PasswordFieldProps {
value: string;
onChange: (value: string) => void;
/** When false, the component renders nothing (T-09-02: no DOM reflow) */
show: boolean;
}
export function PasswordField({ value, onChange, show }: PasswordFieldProps) {
const t = useTranslations('certManager');
const [revealed, setRevealed] = useState(false);
if (!show) return null;
return (
<div className="space-y-1">
<label className="block text-sm text-foreground">
{t('password.label')}
</label>
<div className="relative flex items-center">
<input
type={revealed ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground pr-10 focus:outline-none focus:ring-2 focus:ring-ring"
autoComplete="off"
/>
<button
type="button"
onClick={() => setRevealed((prev) => !prev)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={revealed ? 'Passwort verbergen' : 'Passwort anzeigen'}
>
{revealed ? (
/* Eye-off inline SVG */
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
/* Eye inline SVG */
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
'use client';
import { useTranslations } from 'next-intl';
interface SplitTabProps {
file: File | null;
pemText: string;
password: string;
}
export function SplitTab({ file: _file, pemText: _pemText, password: _password }: SplitTabProps) {
const t = useTranslations('certManager');
return (
<div className="space-y-4">
<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 text-muted-foreground">{t('emptyState.splitBody')}</p>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { DropZone } from './components/DropZone';
import { PasswordField } from './components/PasswordField';
import { InspectTab } from './components/InspectTab';
import { SplitTab } from './components/SplitTab';
import { MergeTab } from './components/MergeTab';
import { ConvertTab } from './components/ConvertTab';
type TabId = 'inspect' | 'split' | 'merge' | 'convert';
const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert'];
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
function isPfxFile(file: File | null): boolean {
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.pfx') || name.endsWith('.p12');
}
/**
* CertManagerPage — tab-based shell for certificate operations.
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
* T-09-02: password lives in local React state only; never logged or placed in URLs.
* T-09-04: all API calls via postForm() which sends credentials:'include'.
*/
export default function CertManagerPage() {
const t = useTranslations('certManager');
const [activeTab, setActiveTab] = useState<TabId>('inspect');
const [file, setFile] = useState<File | null>(null);
const [pemText, setPemText] = useState('');
const [password, setPassword] = useState('');
const showPassword = isPfxFile(file);
const handleFile = (selected: File) => {
setFile(selected);
setPemText(''); // Single active source: file XOR paste
setPassword('');
};
const handleClearFile = () => {
setFile(null);
setPassword('');
};
const handlePemChange = (text: string) => {
setPemText(text);
if (text) {
setFile(null); // Single active source: file XOR paste
setPassword('');
}
};
const handleTabChange = (tab: TabId) => {
setActiveTab(tab);
// Tab switch: keep shared input, clear result (result cleared per tab re-render)
};
const renderActiveTab = () => {
const props = { file, pemText, password };
switch (activeTab) {
case 'inspect':
return <InspectTab {...props} />;
case 'split':
return <SplitTab {...props} />;
case 'merge':
return <MergeTab {...props} />;
case 'convert':
return <ConvertTab {...props} />;
}
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* Shared input card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
{/* DropZone */}
<DropZone
onFile={handleFile}
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
currentFile={file}
onClear={handleClearFile}
/>
{/* ODER divider */}
<div className="flex items-center gap-3">
<hr className="flex-1 border-border" />
<span className="text-xs text-muted-foreground">{t('or')}</span>
<hr className="flex-1 border-border" />
</div>
{/* PEM textarea */}
<textarea
value={pemText}
onChange={(e) => handlePemChange(e.target.value)}
placeholder={t('paste.placeholder')}
rows={4}
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
/>
{/* Conditional password field (T-09-02) */}
<PasswordField
value={password}
onChange={setPassword}
show={showPassword}
/>
</div>
{/* Tab navigation */}
<nav className="border-b border-border flex gap-6">
{TABS.map((tab) => (
<button
key={tab}
type="button"
onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`tabs.${tab}`)}
</button>
))}
</nav>
{/* Tab content card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
{renderActiveTab()}
</div>
</div>
);
}