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:
@@ -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"
|
||||
>
|
||||
✕
|
||||
</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()}
|
||||
|
||||
Reference in New Issue
Block a user