feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich

- Ein gemeinsamer Arbeitsbereich im Browser: jede Auswahl hängt an, eine zweite Datei ersetzt nie die erste
- Ein Parser (node:crypto) für RSA- und EC-Zertifikate als PEM, DER und TRUSTED CERTIFICATE, Rolle je Datei
- Neue Route analyze (mehrere Dateien, 30 x 5 MiB, zusammen 20 MiB); alte Routen, Service und Web-Reiter entfernt
- Test-PKI-Fixtures (RSA und EC, Ketten, Schlüssel, CSR, PFX) mit Erzeugungsskript

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:52:29 +02:00
parent e079c307e4
commit 75ea83fc01
84 changed files with 2906 additions and 5160 deletions
@@ -1,160 +1,38 @@
'use client';
import { PageHeader } from '@/components/layout/page-header';
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';
import { OverviewTab } from './components/OverviewTab';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { FilesTab } from './components/FilesTab';
import { useCertWorkspace } from './use-cert-workspace';
type TabId = 'overview' | '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 {
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.pfx') || name.endsWith('.p12');
}
type TabId = 'files';
/**
* 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'.
*
* The shared PasswordField is shown when:
* 1. A PFX/P12 file is selected in the shared DropZone, OR
* 2. The active tab (Merge or Convert) has PFX chosen as the output format.
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
* Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter (ab Task 2) arbeiten auf dieser Liste.
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie.
*/
export default function CertManagerPage() {
const t = useTranslations('certManager');
const workspace = useCertWorkspace();
const [activeTab, setActiveTab] = useState<TabId>('files');
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [file, setFile] = useState<File | null>(null);
const [pemText, setPemText] = useState('');
const [password, setPassword] = useState('');
// Track the output format of the active Merge and Convert tabs
// so the shared PasswordField can be shown when PFX output is chosen.
const [mergeOutputFormat, setMergeOutputFormat] = useState('pem');
const [convertOutputFormat, setConvertOutputFormat] = useState('pem');
const showPassword =
isPfxFile(file) ||
(activeTab === 'merge' && mergeOutputFormat === 'pfx') ||
(activeTab === 'convert' && convertOutputFormat === 'pfx');
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 = () => {
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} />;
case 'convert':
return (
<ConvertTab
file={file}
pemText={pemText}
password={password}
onTargetFormatChange={setConvertOutputFormat}
/>
);
}
};
const count = workspace.entries.length;
const tabs: { id: TabId; label: string }[] = [
{
id: 'files',
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
},
];
return (
<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')} />
{/* Tab navigation */}
<nav className="flex gap-6 overflow-x-auto border-b border-border">
{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-strong font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`tabs.${tab}`)}
</button>
))}
</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()}
<TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'files' && <FilesTab workspace={workspace} />}
</div>
</div>
);