feat(cert-manager): Zertifikatspaket hochladen, erkennen und in jedem Format herunterladen

Neuer erster Reiter Übersicht: mehrere Dateien oder die ZIP vom Aussteller
auf einmal ablegen. Der Server erkennt jedes Teil (Server-, Zwischen-,
Stammzertifikat, privater Schlüssel, CSR, auch aus PFX/P7B), fasst
Duplikate zusammen, ordnet Schlüssel/CSR dem Zertifikat zu und baut die
Kette. Unter jedem Teil stehen Download-Knöpfe für alle passenden Formate
(crt, cer, Fullchain, p7b, pfx mit Schlüssel und Kette; key PKCS#8/PKCS#1/
DER; csr PEM/DER). Geschützte PFX lassen sich mit Passwort entsperren.

Neue Endpunkte POST analyze (Dateien/ZIP, Begrenzung Anzahl und Größe vor
dem Entpacken) und POST export (JSON, zustandslos). Modultexte siezen jetzt
durchgehend; Gültigkeit in UTC wie im Zertifikat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:23:34 +02:00
parent 645c5e5887
commit c07b0cfaf0
11 changed files with 1811 additions and 52 deletions
@@ -208,3 +208,81 @@ export async function postForm(
return response.json();
}
// ---------------------------------------------------------------------------
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
// ---------------------------------------------------------------------------
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
export type BundleExportFormat =
| 'crt'
| 'cer'
| 'fullchain'
| 'p7b'
| 'pfx'
| 'key'
| 'key-rsa'
| 'key-der'
| 'csr'
| 'csr-der';
export interface BundleItem {
id: string;
kind: BundleItemKind;
role?: CertRole;
sources: string[];
pem: string;
baseName: string;
cn: string;
organization: string;
issuerCn: string;
notBefore: string | null;
notAfter: string | null;
isExpired: boolean | null;
daysLeft: number | null;
san: string[];
keyType: string;
keyBits: number;
serialNumber: string;
sha256: string;
matchId: string | null;
chainIds: string[];
formats: BundleExportFormat[];
}
export interface BundleAnalysis {
items: BundleItem[];
locked: string[];
ignored: string[];
}
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
const form = new FormData();
for (const file of files) form.append('files', file);
if (password) form.append('password', password);
return postForm('analyze', form) as Promise<BundleAnalysis>;
}
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
export async function exportBundleItemAction(input: {
kind: BundleItemKind;
pem: string;
format: BundleExportFormat;
baseName: string;
chain?: string[];
keyPem?: string;
password?: string;
}): Promise<FileResponse> {
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
credentials: 'include',
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`${response.status} ${body}`.trim());
}
return response.json() as Promise<FileResponse>;
}
@@ -7,6 +7,7 @@ vi.mock('next-intl', () => ({
const certManager: Record<string, string> = {
'title': 'Zertifikat-Manager',
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
'tabs.overview': 'Uebersicht',
'tabs.inspect': 'Analysieren',
'tabs.split': 'Aufteilen',
'tabs.merge': 'Zusammenfuehren',
@@ -76,8 +77,15 @@ describe('CertManagerPage shell', () => {
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
});
it('shows Inspect tab empty state on initial render', () => {
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
render(<CertManagerPage />);
expect(screen.getByText('dropTitle')).toBeInTheDocument();
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
});
it('shows Inspect tab empty state when Inspect tab is active', () => {
render(<CertManagerPage />);
fireEvent.click(screen.getAllByText('Analysieren')[0]);
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
});
@@ -0,0 +1,202 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key} ${JSON.stringify(values)}` : key,
}));
vi.mock('../actions', () => ({
analyzeBundleAction: vi.fn(),
exportBundleItemAction: vi.fn(),
downloadBase64: vi.fn(),
}));
import {
analyzeBundleAction,
type BundleItem,
downloadBase64,
exportBundleItemAction,
} from '../actions';
import { OverviewTab } from './OverviewTab';
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
function item(over: Partial<BundleItem>): BundleItem {
return {
id: 'x',
kind: 'certificate',
sources: ['a.pem'],
pem: 'PEM',
baseName: 'www.example.test',
cn: 'www.example.test',
organization: '',
issuerCn: '',
notBefore: null,
notAfter: null,
isExpired: null,
daysLeft: null,
san: [],
keyType: 'RSA',
keyBits: 2048,
serialNumber: '',
sha256: '',
matchId: null,
chainIds: [],
formats: [],
...over,
};
}
const LEAF = item({
id: 'leaf',
role: 'end-entity',
pem: 'LEAF-PEM',
issuerCn: 'Test Intermediate CA',
notBefore: '2026-01-01T00:00:00.000Z',
notAfter: '2027-01-01T00:00:00.000Z',
isExpired: false,
daysLeft: 200,
san: ['www.example.test', 'example.test'],
matchId: 'key',
chainIds: ['inter'],
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
});
const INTER = item({
id: 'inter',
role: 'intermediate',
pem: 'INTER-PEM',
cn: 'Test Intermediate CA',
isExpired: false,
daysLeft: 900,
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
});
const KEY = item({
id: 'key',
kind: 'privateKey',
pem: 'KEY-PEM',
matchId: 'leaf',
formats: ['key', 'key-rsa', 'key-der'],
});
const CSR = item({
id: 'csr',
kind: 'csr',
pem: 'CSR-PEM',
matchId: 'leaf',
formats: ['csr', 'csr-der'],
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function upload(files: File[]) {
render(<OverviewTab />);
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
}
describe('OverviewTab', () => {
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
mockAnalyze.mockResolvedValue({
items: [LEAF, INTER, KEY, CSR],
locked: [],
ignored: ['.dnstxtrecord'],
});
const zip = new File(['zip'], 'paket.zip');
await upload([zip]);
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
const cards = await screen.findAllByTestId('bundle-item');
expect(cards).toHaveLength(4);
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
// Zuordnung und Gueltigkeit
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
// alle Formate als Knoepfe
expect(
within(cards[0])
.getAllByRole('button')
.map((b) => b.textContent),
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
expect(screen.getByText(/ignored/)).toBeInTheDocument();
});
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
mockExport.mockResolvedValue({
filename: 'www.example.test-fullchain.pem',
content: 'Zm9v',
mimeType: 'x',
});
await upload([new File(['x'], 'a.pem')]);
const [leafCard] = await screen.findAllByTestId('bundle-item');
fireEvent.click(within(leafCard).getByText('format.fullchain'));
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
);
expect(mockExport).toHaveBeenCalledWith({
kind: 'certificate',
pem: 'LEAF-PEM',
format: 'fullchain',
baseName: 'www.example.test',
chain: ['INTER-PEM'],
keyPem: 'KEY-PEM',
password: undefined,
});
});
it('PFX fragt erst ein Passwort ab', async () => {
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
mockExport.mockResolvedValue({
filename: 'www.example.test.pfx',
content: 'Zm9v',
mimeType: 'x',
});
await upload([new File(['x'], 'a.pem')]);
const [leafCard] = await screen.findAllByTestId('bundle-item');
fireEvent.click(within(leafCard).getByText('format.pfx'));
expect(mockExport).not.toHaveBeenCalled();
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
const download = within(leafCard).getByText('pfxDownload');
expect(download).toBeDisabled();
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
target: { value: 'geheim' },
});
fireEvent.click(download);
await waitFor(() =>
expect(mockExport).toHaveBeenCalledWith(
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
),
);
});
it('geschuetzte PFX: Passwort eingeben und erneut pruefen', async () => {
mockAnalyze.mockResolvedValueOnce({ items: [LEAF], locked: ['a.pfx'], ignored: [] });
const pfx = new File(['x'], 'a.pfx');
await upload([pfx]);
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
fireEvent.click(screen.getByText('unlock'));
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
});
it('Fehler beim Pruefen wird angezeigt', async () => {
mockAnalyze.mockRejectedValue(new Error('400'));
await upload([new File(['x'], 'a.txt')]);
expect(await screen.findByText('error')).toBeInTheDocument();
});
});
@@ -0,0 +1,355 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import {
analyzeBundleAction,
type BundleAnalysis,
type BundleExportFormat,
type BundleItem,
downloadBase64,
exportBundleItemAction,
} from '../actions';
/**
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
*
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
*/
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
const ROLE_STYLES: Record<string, string> = {
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
csr: 'bg-muted text-muted-foreground',
};
function typeKey(item: BundleItem): string {
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
}
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
function formatDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
export function OverviewTab() {
const t = useTranslations('certManager.overview');
const inputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>([]);
const [password, setPassword] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<BundleAnalysis | null>(null);
async function analyze(next: File[], pw: string) {
if (next.length === 0) {
setResult(null);
return;
}
setLoading(true);
setError(null);
try {
setResult(await analyzeBundleAction(next, pw || undefined));
} catch {
setResult(null);
setError(t('error'));
} finally {
setLoading(false);
}
}
function addFiles(list: FileList | null) {
const added = Array.from(list ?? []);
if (added.length === 0) return;
const next = [...files, ...added];
setFiles(next);
void analyze(next, password);
}
function removeFile(index: number) {
const next = files.filter((_, i) => i !== index);
setFiles(next);
void analyze(next, password);
}
function reset() {
setFiles([]);
setPassword('');
setResult(null);
setError(null);
}
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
return (
<div className="space-y-6">
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT}
className="hidden"
data-testid="overview-file-input"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
addFiles(e.dataTransfer.files);
}}
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
}`}
>
<p className="text-sm text-foreground">{t('dropTitle')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
</button>
{files.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{files.map((file, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
key={`${file.name}-${i}`}
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
>
{file.name}
<button
type="button"
aria-label={t('removeFile', { name: file.name })}
onClick={() => removeFile(i)}
className="text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
</span>
))}
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
{t('reset')}
</button>
</div>
)}
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{result && result.locked.length > 0 && (
<div className="space-y-2 rounded-lg border border-status-warn/50 bg-status-warn/10 p-4">
<p className="text-sm text-foreground">
{t('locked', { files: result.locked.join(', ') })}
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('lockedPassword')}
aria-label={t('lockedPassword')}
autoComplete="off"
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
/>
<button
type="button"
className="btn btn-secondary"
disabled={!password || loading}
onClick={() => void analyze(files, password)}
>
{t('unlock')}
</button>
</div>
</div>
)}
{result && result.items.length === 0 && !loading && (
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
)}
{result && result.items.length > 0 && (
<ul className="space-y-4">
{result.items.map((item) => (
<BundleItemCard key={item.id} item={item} byId={byId} />
))}
</ul>
)}
{result && result.ignored.length > 0 && (
<p className="text-xs text-muted-foreground">
{t('ignored', { files: result.ignored.join(', ') })}
</p>
)}
</div>
);
}
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
const t = useTranslations('certManager.overview');
const [pfxOpen, setPfxOpen] = useState(false);
const [pfxPassword, setPfxPassword] = useState('');
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
const [error, setError] = useState<string | null>(null);
const match = item.matchId ? byId[item.matchId] : undefined;
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
async function download(format: BundleExportFormat, password?: string) {
setBusy(format);
setError(null);
try {
const file = await exportBundleItemAction({
kind: item.kind,
pem: item.pem,
format,
baseName: item.baseName,
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
password,
});
downloadBase64(file.filename, file.content, file.mimeType);
if (format === 'pfx') {
setPfxOpen(false);
setPfxPassword('');
}
} catch {
setError(t('exportError'));
} finally {
setBusy(null);
}
}
const status =
item.isExpired === null
? null
: item.isExpired
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
: (item.daysLeft ?? 0) <= 30
? {
cls: 'bg-status-warn/15 text-status-warn-fg',
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
}
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
return (
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
{t(`type.${typeKey(item)}`)}
</span>
<span className="font-medium break-all">{item.cn || item.baseName}</span>
{status && (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
{status.text}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
{item.kind === 'certificate' && (
<>
<dt className="text-muted-foreground">{t('issuer')}</dt>
<dd className="break-all">{item.issuerCn}</dd>
<dt className="text-muted-foreground">{t('validity')}</dt>
<dd>
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
</dd>
</>
)}
{item.san.length > 0 && (
<>
<dt className="text-muted-foreground">{t('names')}</dt>
<dd className="break-all">{item.san.join(', ')}</dd>
</>
)}
{item.keyType && (
<>
<dt className="text-muted-foreground">{t('key')}</dt>
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
</>
)}
{match && (
<>
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
<dd className="break-all">
{t(`type.${typeKey(match)}`)} {match.cn}
</dd>
</>
)}
<dt className="text-muted-foreground">{t('source')}</dt>
<dd className="break-all">{item.sources.join(', ')}</dd>
</dl>
{item.kind === 'privateKey' && (
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{item.formats.map((format) => (
<button
key={format}
type="button"
disabled={busy !== null}
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
title={t(`formatHint.${format}`)}
>
{busy === format ? t('downloading') : t(`format.${format}`)}
</button>
))}
</div>
{pfxOpen && (
<div className="mt-3 space-y-2 rounded border border-border p-3">
<p className="text-xs text-muted-foreground">
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="password"
value={pfxPassword}
onChange={(e) => setPfxPassword(e.target.value)}
placeholder={t('pfxPassword')}
aria-label={t('pfxPassword')}
autoComplete="new-password"
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
/>
<button
type="button"
className="btn btn-primary"
disabled={!pfxPassword || busy !== null}
onClick={() => void download('pfx', pfxPassword)}
>
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
</button>
</div>
</div>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</li>
);
}
@@ -9,10 +9,11 @@ import { InspectTab } from './components/InspectTab';
import { SplitTab } from './components/SplitTab';
import { MergeTab } from './components/MergeTab';
import { ConvertTab } from './components/ConvertTab';
import { OverviewTab } from './components/OverviewTab';
type TabId = 'inspect' | 'split' | 'merge' | 'convert';
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert'];
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
function isPfxFile(file: File | null): boolean {
@@ -23,6 +24,7 @@ function isPfxFile(file: File | null): boolean {
/**
* CertManagerPage — tab-based shell for certificate operations.
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
* 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'.
@@ -34,7 +36,7 @@ function isPfxFile(file: File | null): boolean {
export default function CertManagerPage() {
const t = useTranslations('certManager');
const [activeTab, setActiveTab] = useState<TabId>('inspect');
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [file, setFile] = useState<File | null>(null);
const [pemText, setPemText] = useState('');
const [password, setPassword] = useState('');
@@ -75,17 +77,14 @@ export default function CertManagerPage() {
const renderActiveTab = () => {
switch (activeTab) {
case 'overview':
return <OverviewTab />;
case 'inspect':
return <InspectTab file={file} pemText={pemText} password={password} />;
case 'split':
return <SplitTab file={file} pemText={pemText} password={password} />;
case 'merge':
return (
<MergeTab
password={password}
onOutputFormatChange={setMergeOutputFormat}
/>
);
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
case 'convert':
return (
<ConvertTab
@@ -102,42 +101,8 @@ export default function CertManagerPage() {
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
{/* Shared input card */}
<div className="rounded-lg bg-card dark:border dark:border-border 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">
<nav className="flex gap-6 overflow-x-auto border-b border-border">
{TABS.map((tab) => (
<button
key={tab}
@@ -154,6 +119,39 @@ export default function CertManagerPage() {
))}
</nav>
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
{activeTab !== 'overview' && (
<div className="rounded-lg bg-card dark:border dark:border-border 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 content card */}
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
{renderActiveTab()}