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,110 +1,190 @@
export const API_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// ---------------------------------------------------------------------------
// SplitResponse — mirrors CertManagerService.SplitResponse
// ---------------------------------------------------------------------------
export type CertRole = 'root' | 'intermediate' | 'end-entity';
export interface SplitEntry {
index: number;
filename: string;
/** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */
content: string;
subject: { cn: string };
validity: { notAfter: string };
certRole: CertRole;
}
export interface SplitResponse {
count: number;
certs: SplitEntry[];
}
// ---------------------------------------------------------------------------
// CertDetails — mirrors the API response shape from CertManagerService
// ---------------------------------------------------------------------------
export interface CertDetails {
subject: { cn: string; o: string; ou: string; c: string };
issuer: { cn: string; o: string; c: string };
validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
san: string[];
keyType: string;
keyBits: number;
serialNumber: string;
signatureAlgorithm: string;
fingerprint: { sha1: string; sha256: string };
pemPreview: string;
}
// ---------------------------------------------------------------------------
// inspectCertAction — calls POST /modules/cert-manager/parse
// ---------------------------------------------------------------------------
import { toFormData, type WorkingEntry } from './working-set';
/**
* Call POST /modules/cert-manager/parse.
* - If pemText is present → JSON body { pemText, password }
* - Otherwise → multipart FormData with file + optional password
* Aufrufe der Zertifikat-Manager-API (quick-261009-ikt). Die Typen spiegeln 1:1
* apps/api/src/cert-manager/cert-types.ts.
*
* T-09-02: password is never placed in URL, logged, or echoed.
* T-09-04: credentials:'include' ensures JWT cookie is sent.
* Passwoerter und Schluessel stehen nur im Koerper der Anfrage, nie in einer Adresse
* und nie in einer Konsolenausgabe. Der Browser sendet das Sitzungs-Cookie mit.
*/
export async function inspectCertAction(input: {
file?: File | null;
pemText?: string;
password?: string;
}): Promise<CertDetails> {
const { file, pemText, password } = input;
if (pemText) {
// JSON path — content-type must be application/json (not multipart)
const response = await fetch(`${API_URL}/modules/cert-manager/parse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pemText, password }),
credentials: 'include',
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`${response.status} ${body}`.trim());
}
return response.json() as Promise<CertDetails>;
} else {
// Multipart path — let browser set Content-Type with boundary
const form = new FormData();
if (file) form.append('file', file);
if (password) form.append('password', password);
return postForm('parse', form) as Promise<CertDetails>;
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface ItemSource {
file: number;
path: string;
}
export type CertRole = 'end-entity' | 'intermediate' | 'root';
export interface CertItem {
id: string;
kind: 'certificate';
role: CertRole;
sources: ItemSource[];
pem: string;
baseName: string;
cn: string;
organization: string;
issuerCn: string;
issuerOrganization: string;
notBefore: string;
notAfter: string;
isExpired: boolean;
daysLeft: number;
san: string[];
keyType: string;
keyBits: number | null;
curve: string | null;
serialNumber: string;
sha256: string;
sha1: string;
isCa: boolean;
selfSigned: boolean;
aiaIssuerUrls: string[];
keyId: string | null;
csrIds: string[];
}
export interface KeyItem {
id: string;
kind: 'privateKey';
sources: ItemSource[];
pem: string;
baseName: string;
keyType: string;
keyBits: number | null;
curve: string | null;
wasEncrypted: boolean;
certIds: string[];
}
export interface CsrItem {
id: string;
kind: 'csr';
sources: ItemSource[];
pem: string;
baseName: string;
cn: string;
organization: string;
san: string[];
keyType: string;
keyBits: number | null;
curve: string | null;
keyId: string | null;
certIds: string[];
}
export type AnyItem = CertItem | KeyItem | CsrItem;
export interface ChainGap {
certId: string;
kind: 'afterLeaf' | 'afterCa';
missingIssuerCn: string;
aiaUrls: string[];
}
export interface ChainInfo {
headId: string;
path: string[];
rootId: string | null;
complete: boolean;
gap: ChainGap | null;
alternatives: number;
}
export interface LockedEntry {
file: number;
path: string;
container: 'pkcs12' | 'privateKey';
reason: 'passwordNeeded' | 'passwordWrong';
}
export type IgnoredReason =
| 'unknown'
| 'nestedZip'
| 'encryptedZip'
| 'brokenZip'
| 'tooLarge'
| 'suspicious'
| 'zipTooLarge'
| 'tooManyEntries'
| 'unsupportedKey';
export interface IgnoredEntry {
file: number;
path: string;
reason: IgnoredReason;
}
export interface AnalysisResult {
items: AnyItem[];
chains: ChainInfo[];
locked: LockedEntry[];
ignored: IgnoredEntry[];
}
/** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */
export class CertManagerRequestError extends Error {
constructor(
readonly status: number,
readonly code: string | null,
) {
super(`cert-manager request failed (${status}${code ? ` ${code}` : ''})`);
this.name = 'CertManagerRequestError';
}
}
// ---------------------------------------------------------------------------
// splitCertsAction — calls POST /modules/cert-manager/split
// ---------------------------------------------------------------------------
const KNOWN_ERROR_CODES = new Set([
'invalidInput',
'notACertificate',
'noChain',
'keyMissing',
'keyMismatch',
'passwordRequired',
'formatNotPossible',
'templateNeedsKey',
'tooLarge',
'aiaMissing',
'aiaInternal',
'aiaNotIssuer',
'aiaUnreachable',
'aiaTooLarge',
]);
/**
* Upload a fullchain PEM or P7B bundle to POST /modules/cert-manager/split.
* Returns a SplitResponse with one entry per certificate in the bundle.
*
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
*/
export async function splitCertsAction(file: File): Promise<SplitResponse> {
const form = new FormData();
form.append('file', file);
return postForm('split', form) as Promise<SplitResponse>;
/** Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst generic. */
export function certErrorKey(error: unknown): string {
if (error instanceof CertManagerRequestError) {
if (error.code && KNOWN_ERROR_CODES.has(error.code)) return error.code;
if (error.status === 413) return 'tooLarge';
}
return 'generic';
}
/**
* 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 {
async function errorFromResponse(response: Response): Promise<CertManagerRequestError> {
let code: string | null = null;
try {
const body = (await response.json()) as { code?: unknown };
if (typeof body.code === 'string') code = body.code;
} catch {
// Antwort ohne JSON (z. B. 413 vom Proxy): nur der Status zaehlt
}
return new CertManagerRequestError(response.status, code);
}
/** POST /modules/cert-manager/analyze: der ganze Arbeitsbereich in Reihenfolge, Antwort mit allen erkannten Teilen. */
export async function analyzeWorkingSet(entries: WorkingEntry[]): Promise<AnalysisResult> {
const response = await fetch(`${API_URL}/modules/cert-manager/analyze`, {
method: 'POST',
body: toFormData(entries),
credentials: 'include',
});
if (!response.ok) throw await errorFromResponse(response);
return (await response.json()) as AnalysisResult;
}
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
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++) {
@@ -118,171 +198,3 @@ export function downloadBase64(
anchor.click();
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// FileResponse — mirrors CertManagerService.FileResponse
// ---------------------------------------------------------------------------
export interface FileResponse {
/** Suggested download filename, e.g. "converted.der" */
filename: string;
/** Base64-encoded file content */
content: string;
/** MIME type for the download */
mimeType: string;
}
// ---------------------------------------------------------------------------
// mergeCertsAction — calls POST /modules/cert-manager/merge
// ---------------------------------------------------------------------------
/**
* Upload multiple certificate files to POST /modules/cert-manager/merge.
* The API requires at least 2 files (enforced by controller).
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
*
* T-09-02: password is never placed in URL, logged, or echoed.
* T-09-03: maxCount 20 per FilesInterceptor (enforced server-side).
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
*/
export async function mergeCertsAction(
files: File[],
outputFormat: string,
password?: string,
): Promise<FileResponse> {
const form = new FormData();
files.forEach((file) => {
form.append('files', file);
});
form.append('outputFormat', outputFormat);
if (password) form.append('password', password);
return postForm('merge', form) as Promise<FileResponse>;
}
// ---------------------------------------------------------------------------
// convertCertAction — calls POST /modules/cert-manager/convert
// ---------------------------------------------------------------------------
/**
* Upload a certificate and convert it to the chosen target format.
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
*
* T-09-02: password is never placed in URL, logged, or echoed.
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
*/
export async function convertCertAction(
input: { file?: File | null; pemText?: string; password?: string },
targetFormat: string,
): Promise<FileResponse> {
const { file, pemText, password } = input;
const form = new FormData();
if (file) form.append('file', file);
if (pemText) form.append('pemText', pemText);
if (password) form.append('password', password);
form.append('targetFormat', targetFormat);
return postForm('convert', form) as Promise<FileResponse>;
}
/**
* 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();
}
// ---------------------------------------------------------------------------
// 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>;
}
@@ -1,438 +1,70 @@
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import CertManagerPage from './page';
// 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.overview': 'Uebersicht',
'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;
},
const mockAnalyze = vi.fn();
vi.mock('./actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('./actions')>();
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
});
vi.mock('@/components/layout/page-header', () => ({
PageHeader: ({ title, description }: { title: string; description?: string }) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
import CertManagerPage from './page';
import { InspectTab } from './components/InspectTab';
import { SplitTab } from './components/SplitTab';
import { MergeTab } from './components/MergeTab';
import { ConvertTab } from './components/ConvertTab';
import * as actions from './actions';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
beforeEach(() => {
mockAnalyze.mockReset();
mockAnalyze.mockResolvedValue({ items: [], chains: [], locked: [], ignored: [] });
});
afterEach(cleanup);
describe('CertManagerPage shell', () => {
it('renders the page title and description', () => {
describe('CertManagerPage', () => {
it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => {
render(<CertManagerPage />);
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
const tabs = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current'));
expect(tabs).toHaveLength(1);
expect(tabs[0]).toHaveTextContent('Dateien');
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
// Startansicht ist der Dateien-Reiter
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
});
it('renders all four tab labels', () => {
it('der Reiter zeigt die Anzahl der Dateien', async () => {
render(<CertManagerPage />);
// "Analysieren" appears as both tab label (nav) and InspectTab action button
// getAllByText avoids the "multiple elements" error
expect(screen.getAllByText('Analysieren').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Aufteilen')).toBeInTheDocument();
expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument();
expect(screen.getByText('Konvertieren')).toBeInTheDocument();
const input = screen.getByTestId('cert-file-input');
fireEvent.change(input, {
target: {
files: [
new File(['a'], 'a.pem', { lastModified: 1 }),
new File(['b'], 'b.pem', { lastModified: 2 }),
],
},
});
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Dateien (2)' })).toBeInTheDocument(),
);
});
it('does NOT render the password field on initial render (no PFX file selected)', () => {
it('ruft die Analyse nicht ohne Dateien auf', () => {
render(<CertManagerPage />);
// Password field label should not be in document when no PFX is selected
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
});
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();
});
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();
});
});
// ---------------------------------------------------------------------------
// InspectTab — unit tests for the Analysieren result grid and error state
// ---------------------------------------------------------------------------
const MOCK_CERT_DETAILS: actions.CertDetails = {
subject: { cn: 'example.com', o: 'Acme Corp', ou: 'IT', c: 'DE' },
issuer: { cn: 'Example CA', o: 'CA Corp', c: 'US' },
validity: {
notBefore: '2025-01-01T00:00:00.000Z',
notAfter: '2026-01-01T00:00:00.000Z',
isExpired: false,
daysLeft: 180,
},
san: ['example.com', 'www.example.com'],
keyType: 'RSA',
keyBits: 2048,
serialNumber: '01',
signatureAlgorithm: 'sha256WithRSAEncryption',
fingerprint: {
sha1: 'AA:BB:CC:DD:EE',
sha256: 'FF:00:11:22:33:44:55:66:77:88',
},
pemPreview: '-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----',
};
describe('InspectTab', () => {
it('renders CertDetails grid with subject CN and SHA-256 after successful inspect', async () => {
vi.spyOn(actions, 'inspectCertAction').mockResolvedValue(MOCK_CERT_DETAILS);
render(
<InspectTab
file={null}
pemText="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
password=""
/>,
);
// Click the Analysieren button
fireEvent.click(screen.getByText('Analysieren'));
// Wait for the async action to complete and result grid to render
await waitFor(() => {
expect(screen.getByText('example.com')).toBeInTheDocument();
});
// SHA-256 fingerprint is rendered
expect(screen.getByText('FF:00:11:22:33:44:55:66:77:88')).toBeInTheDocument();
// Empty state is no longer shown
expect(screen.queryByText('Kein Zertifikat geladen.')).not.toBeInTheDocument();
});
it('shows text-destructive error when inspectCertAction rejects', async () => {
vi.spyOn(actions, 'inspectCertAction').mockRejectedValue(
new Error('wrong password'),
);
render(
<InspectTab
file={null}
pemText="test pem"
password="wrong"
/>,
);
fireEvent.click(screen.getByText('Analysieren'));
// Wait for the error message to appear
await waitFor(() => {
// The wrong-password error message should be rendered
expect(
screen.getByText('Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.'),
).toBeInTheDocument();
});
// Error element should have the destructive class
const errorEl = screen.getByText(
'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.',
);
expect(errorEl.className).toContain('text-destructive');
});
});
// ---------------------------------------------------------------------------
// SplitTab — unit tests
// ---------------------------------------------------------------------------
const MOCK_SPLIT_RESPONSE: actions.SplitResponse = {
count: 2,
certs: [
{
index: 0,
filename: 'cert-1.pem',
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----'),
subject: { cn: 'cert1.example.com' },
validity: { notAfter: '2027-01-01T00:00:00.000Z' },
},
{
index: 1,
filename: 'cert-2.pem',
content: btoa('-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
subject: { cn: 'cert2.example.com' },
validity: { notAfter: '2027-06-01T00:00:00.000Z' },
},
],
};
describe('SplitTab', () => {
it('renders two download buttons after clicking Aufteilen with mocked splitCertsAction', async () => {
vi.spyOn(actions, 'splitCertsAction').mockResolvedValue(MOCK_SPLIT_RESPONSE);
const testFile = new File(['cert data'], 'fullchain.pem', {
type: 'application/x-pem-file',
});
render(<SplitTab file={testFile} pemText="" password="" />);
// Initial empty state
expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument();
// Click the Aufteilen button (only one in this isolated render)
fireEvent.click(screen.getByText('Aufteilen'));
// Wait for the async action to resolve and per-cert list to appear
await waitFor(() => {
expect(screen.getAllByText('Herunterladen').length).toBe(2);
});
// Empty state is gone
expect(screen.queryByText('Keine Datei geladen.')).not.toBeInTheDocument();
// Both cert CN values are rendered
expect(screen.getByText('cert1.example.com')).toBeInTheDocument();
expect(screen.getByText('cert2.example.com')).toBeInTheDocument();
});
it('shows text-destructive error when splitCertsAction rejects', async () => {
vi.spyOn(actions, 'splitCertsAction').mockRejectedValue(
new Error('invalid format or corrupted file'),
);
const testFile = new File(['garbage'], 'bad.pem', { type: 'text/plain' });
render(<SplitTab file={testFile} pemText="" password="" />);
fireEvent.click(screen.getByText('Aufteilen'));
await waitFor(() => {
expect(
screen.getByText('Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.'),
).toBeInTheDocument();
});
const errorEl = screen.getByText(
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
);
expect(errorEl.className).toContain('text-destructive');
});
});
// ---------------------------------------------------------------------------
// ConvertTab — unit tests
// ---------------------------------------------------------------------------
const MOCK_FILE_RESPONSE: actions.FileResponse = {
filename: 'converted.der',
content: btoa('fake der bytes'),
mimeType: 'application/x-x509-ca-cert',
};
describe('ConvertTab', () => {
it('renders format selector with pem, der, p7b options', () => {
render(<ConvertTab file={null} pemText="" password="" />);
const select = screen.getByRole('combobox');
const optionValues = Array.from(select.querySelectorAll('option')).map(
(o) => (o as HTMLOptionElement).value,
);
expect(optionValues).toContain('pem');
expect(optionValues).toContain('der');
expect(optionValues).toContain('p7b');
});
it('calls downloadBase64 after clicking Konvertieren when convertCertAction resolves', async () => {
vi.spyOn(actions, 'convertCertAction').mockResolvedValue(MOCK_FILE_RESPONSE);
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
const testFile = new File(['cert data'], 'cert.pem', {
type: 'application/x-pem-file',
});
render(<ConvertTab file={testFile} pemText="" password="" />);
// Click the Konvertieren button
fireEvent.click(screen.getByText('Konvertieren'));
// Wait for async action to resolve and downloadBase64 to be called
await waitFor(() => {
expect(downloadSpy).toHaveBeenCalledWith(
MOCK_FILE_RESPONSE.filename,
MOCK_FILE_RESPONSE.content,
MOCK_FILE_RESPONSE.mimeType,
);
});
});
it('shows text-destructive error when convertCertAction rejects with format error', async () => {
vi.spyOn(actions, 'convertCertAction').mockRejectedValue(
new Error('unknown format or invalid certificate'),
);
const testFile = new File(['garbage'], 'bad.bin', { type: 'application/octet-stream' });
render(<ConvertTab file={testFile} pemText="" password="" />);
fireEvent.click(screen.getByText('Konvertieren'));
await waitFor(() => {
expect(
screen.getByText(
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
),
).toBeInTheDocument();
});
const errorEl = screen.getByText(
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
);
expect(errorEl.className).toContain('text-destructive');
});
it('renders pem, der, p7b, and pfx options in the format selector', () => {
render(<ConvertTab file={null} pemText="" password="" />);
const select = screen.getByRole('combobox');
const optionValues = Array.from(select.querySelectorAll('option')).map(
(o) => (o as HTMLOptionElement).value,
);
expect(optionValues).toContain('pem');
expect(optionValues).toContain('der');
expect(optionValues).toContain('p7b');
expect(optionValues).toContain('pfx');
});
});
// ---------------------------------------------------------------------------
// MergeTab — unit tests (CERT-03, CERT-05)
// ---------------------------------------------------------------------------
const MOCK_MERGE_PEM_RESPONSE: actions.FileResponse = {
filename: 'chain.pem',
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
mimeType: 'application/x-pem-file',
};
describe('MergeTab', () => {
it('disables Zusammenfuehren action button when fewer than 2 files are selected', () => {
render(<MergeTab password="" />);
// The action button should be disabled (no files yet)
const actionBtn = screen.getByTestId('merge-action-btn');
expect(actionBtn).toBeDisabled();
});
it('enables Zusammenfuehren button when 2 or more files are selected', async () => {
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
await waitFor(() => {
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
});
});
it('shows password field in page.tsx when PFX output is selected in MergeTab', async () => {
// Render the full page so the shared PasswordField behaviour can be tested
render(<CertManagerPage />);
// Navigate to merge tab — only one "Zusammenfuehren" visible before tab switch
fireEvent.click(screen.getByText('Zusammenfuehren'));
// Change format selector to pfx
const formatSelect = screen.getByDisplayValue('PEM-Kette');
fireEvent.change(formatSelect, { target: { value: 'pfx' } });
// The shared PasswordField should now be visible
await waitFor(() => {
expect(screen.getByText('Passwort (PFX/P12)')).toBeInTheDocument();
});
});
it('calls downloadBase64 after successful merge when >= 2 files selected', async () => {
vi.spyOn(actions, 'mergeCertsAction').mockResolvedValue(MOCK_MERGE_PEM_RESPONSE);
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
await waitFor(() => {
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('merge-action-btn'));
await waitFor(() => {
expect(downloadSpy).toHaveBeenCalledWith(
MOCK_MERGE_PEM_RESPONSE.filename,
MOCK_MERGE_PEM_RESPONSE.content,
MOCK_MERGE_PEM_RESPONSE.mimeType,
);
});
expect(mockAnalyze).not.toHaveBeenCalled();
});
});
@@ -1,96 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { convertCertAction, downloadBase64 } from '../actions';
interface ConvertTabProps {
file: File | null;
pemText: string;
password: string;
/** Callback to notify page.tsx when target format changes (for shared PasswordField visibility) */
onTargetFormatChange?: (format: string) => void;
}
type TargetFormat = 'pem' | 'der' | 'p7b' | 'pfx';
export function ConvertTab({ file, pemText, password, onTargetFormatChange }: ConvertTabProps) {
const t = useTranslations('certManager');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem');
function handleFormatChange(format: TargetFormat) {
setTargetFormat(format);
onTargetFormatChange?.(format);
}
async function handleConvert() {
setLoading(true);
setError(null);
try {
const response = await convertCertAction({ file, pemText, password }, targetFormat);
downloadBase64(response.filename, response.content, response.mimeType);
} catch (err) {
const msg = err instanceof Error ? err.message.toLowerCase() : '';
if (msg.includes('password') || msg.includes('passwort')) {
setError(t('error.wrongPassword'));
} else if (
msg.includes('format') ||
msg.includes('invalid') ||
msg.includes('unknown')
) {
setError(t('error.unknownFormat'));
} else {
setError(t('error.generic'));
}
} finally {
setLoading(false);
}
}
const canConvert = Boolean(file || pemText);
return (
<div className="space-y-4">
{/* Target format selector */}
<div className="flex items-center gap-3">
<label htmlFor="targetFormat" className="text-sm font-medium text-foreground">
Ausgabeformat
</label>
<select
id="targetFormat"
value={targetFormat}
onChange={(e) => handleFormatChange(e.target.value as TargetFormat)}
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="pem">PEM</option>
<option value="der">DER</option>
<option value="p7b">P7B</option>
<option value="pfx">PFX / PKCS12</option>
</select>
</div>
{/* Primary action button */}
<button
type="button"
onClick={handleConvert}
disabled={loading || !canConvert}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.convert')}
</button>
{/* Error state */}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Empty state — shown when no error */}
{!error && (
<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>
);
}
@@ -1,108 +0,0 @@
'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<HTMLButtonElement>) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
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}
/>
{/* a11y/useSemanticElements: kein role="button"-div mehr -- die
eigentliche Flaeche ist jetzt ein echtes <button> und traegt darum
auch Klick UND Drag-Ereignisse (ein <div> ohne Rolle mit
Ereignis-Handlern waere ein statisches, nicht interaktives Element
und faellt unter die zurueckgestellten Regeln noStaticElement-
Interactions/noNoninteractiveElementInteractions). Die "Entfernen"-
Schaltflaeche liegt darum als Geschwister daneben, nicht mehr
verschachtelt in der Flaechen-Schaltflaeche -- ein <button> darf
kein weiteres <button> enthalten (ungueltiges Verschachteln). */}
<div className="relative">
<button
type="button"
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
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'
}`}
>
{currentFile ? (
<span className="text-sm text-foreground">
{currentFile.name}{' '}
<span className="text-muted-foreground">
({(currentFile.size / 1024).toFixed(1)} KB)
</span>
</span>
) : (
<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>
)}
</button>
{currentFile && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClear(); }}
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,218 @@
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import type { AnalysisResult, CertItem } from '../actions';
import { CertManagerRequestError } from '../actions';
import { useCertWorkspace } from '../use-cert-workspace';
import { FilesTab } from './FilesTab';
const mockAnalyze = vi.fn();
vi.mock('../actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../actions')>();
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
});
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function Harness() {
const workspace = useCertWorkspace();
return <FilesTab workspace={workspace} />;
}
function cert(id: string, cn: string, role: CertItem['role'], files: number[], names: string[]) {
return {
id,
kind: 'certificate',
role,
sources: files.map((file, i) => ({ file, path: names[i] })),
pem: '',
baseName: cn,
cn,
organization: '',
issuerCn: '',
issuerOrganization: '',
notBefore: '2026-01-01T00:00:00.000Z',
notAfter: '2126-01-01T00:00:00.000Z',
isExpired: false,
daysLeft: 36000,
san: [],
keyType: 'RSA',
keyBits: 2048,
curve: null,
serialNumber: '01',
sha256: '',
sha1: '',
isCa: role !== 'end-entity',
selfSigned: role === 'root',
aiaIssuerUrls: [],
keyId: null,
csrIds: [],
} satisfies CertItem;
}
/** Fake-Server: erkennt anhand des Dateinamens, was in der Datei „steckt“. */
function fakeAnalyze(entries: { file: File }[]): AnalysisResult {
const items: CertItem[] = [];
const ignored: AnalysisResult['ignored'] = [];
entries.forEach((entry, index) => {
const name = entry.file.name;
if (name.startsWith('leaf'))
items.push(cert('c-leaf', 'www.example.test', 'end-entity', [index], [name]));
else if (name.startsWith('inter'))
items.push(cert('c-inter', 'Test Inter', 'intermediate', [index], [name]));
else ignored.push({ file: index, path: name, reason: 'unknown' });
});
return { items, chains: [], locked: [], ignored };
}
function makeFile(name: string, lastModified = 1) {
return new File(['x'], name, { lastModified });
}
function selectFiles(files: File[]) {
const input = screen.getByTestId('cert-file-input') as HTMLInputElement;
fireEvent.change(input, { target: { files } });
}
beforeEach(() => {
mockAnalyze.mockReset();
mockAnalyze.mockImplementation(async (entries: { file: File }[]) => fakeAnalyze(entries));
});
afterEach(cleanup);
describe('FilesTab', () => {
it('zwei Auswahlen nacheinander: beide bleiben, die Analyse bekommt beide (Fehlerbild des Nutzers)', async () => {
render(<Harness />);
selectFiles([makeFile('leaf.pem')]);
await screen.findByText('www.example.test');
selectFiles([makeFile('inter.pem')]);
await screen.findByText('Test Inter');
expect(screen.getByText('leaf.pem')).toBeInTheDocument();
expect(screen.getByText('inter.pem')).toBeInTheDocument();
const lastCall = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
expect(lastCall.map((e) => e.file.name)).toEqual(['leaf.pem', 'inter.pem']);
});
it('eine Auswahl mit zwei Dateien haengt beide an', async () => {
render(<Harness />);
selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]);
await screen.findByText('Test Inter');
expect(screen.getAllByRole('listitem').length).toBeGreaterThanOrEqual(2);
expect(mockAnalyze).toHaveBeenCalledTimes(1);
});
it('Ablegen von Dateien haengt an', async () => {
render(<Harness />);
const zone = screen.getByRole('button', { name: /Dateien hierher ziehen/ });
fireEvent.drop(zone, { dataTransfer: { files: [makeFile('leaf.pem')] } });
await screen.findByText('www.example.test');
fireEvent.drop(zone, { dataTransfer: { files: [makeFile('inter.pem')] } });
await screen.findByText('Test Inter');
expect(screen.getByText('leaf.pem')).toBeInTheDocument();
});
it('Entfernen analysiert den Rest neu, die letzte Datei leert ohne Aufruf', async () => {
render(<Harness />);
selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]);
await screen.findByText('Test Inter');
fireEvent.click(screen.getByRole('button', { name: '„leaf.pem“ entfernen' }));
await waitFor(() => expect(screen.queryByText('leaf.pem')).not.toBeInTheDocument());
await waitFor(() => {
const last = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
expect(last.map((e) => e.file.name)).toEqual(['inter.pem']);
});
const callsBefore = mockAnalyze.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '„inter.pem“ entfernen' }));
await screen.findByText(/Noch keine Dateien/);
expect(mockAnalyze).toHaveBeenCalledTimes(callsBefore);
expect(screen.queryByText('Test Inter')).not.toBeInTheDocument();
});
it('zeigt je Eintrag Rolle und Name der erkannten Zertifikate', async () => {
render(<Harness />);
selectFiles([makeFile('leaf.pem')]);
const item = (await screen.findByText('www.example.test')).closest('li') as HTMLElement;
expect(within(item).getByText('Serverzertifikat')).toBeInTheDocument();
});
it('eine nicht erkannte Datei nennt den Grund', async () => {
render(<Harness />);
selectFiles([makeFile('readme.txt')]);
await screen.findByText('Darin wurde kein Zertifikat erkannt.');
});
it('abgelehnte Dateien werden mit Grund aufgelistet', async () => {
render(<Harness />);
selectFiles([makeFile('leaf.pem', 5)]);
await screen.findByText('www.example.test');
selectFiles([makeFile('leaf.pem', 5)]);
expect(await screen.findByText('„leaf.pem“ ist schon in der Liste.')).toBeInTheDocument();
});
it('der Hinweis, dass die Liste nur in diesem Browserfenster liegt, ist immer sichtbar', () => {
render(<Harness />);
expect(screen.getByText(/nur in diesem Browserfenster/)).toBeInTheDocument();
});
it('zeigt waehrend der Analyse einen Statustext', async () => {
let resolve: (value: AnalysisResult) => void = () => {};
mockAnalyze.mockImplementationOnce(
() =>
new Promise<AnalysisResult>((r) => {
resolve = r;
}),
);
render(<Harness />);
selectFiles([makeFile('leaf.pem')]);
expect(await screen.findByRole('status')).toHaveTextContent('Dateien werden geprüft');
resolve({ items: [], chains: [], locked: [], ignored: [] });
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
});
it('bei einem Analysefehler: Text zum Fehlercode und „Erneut versuchen“ wiederholt den Aufruf', async () => {
mockAnalyze.mockRejectedValueOnce(new CertManagerRequestError(413, 'tooLarge'));
render(<Harness />);
selectFiles([makeFile('leaf.pem')]);
expect(await screen.findByText(/zusammen zu groß/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
await screen.findByText('www.example.test');
expect(mockAnalyze).toHaveBeenCalledTimes(2);
});
it('verwirft die Antwort einer aelteren Anfrage', async () => {
const resolvers: ((value: AnalysisResult) => void)[] = [];
mockAnalyze.mockImplementation(
() =>
new Promise<AnalysisResult>((r) => {
resolvers.push(r);
}),
);
render(<Harness />);
selectFiles([makeFile('leaf.pem')]);
selectFiles([makeFile('inter.pem')]);
await waitFor(() => expect(resolvers).toHaveLength(2));
const second = fakeAnalyze([{ file: makeFile('leaf.pem') }, { file: makeFile('inter.pem') }]);
resolvers[1](second);
await screen.findByText('Test Inter');
resolvers[0](fakeAnalyze([{ file: makeFile('readme.txt') }]));
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText('Test Inter')).toBeInTheDocument();
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,238 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import type { AnyItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import {
formatBytes,
MAX_ENTRIES,
MAX_FILE_BYTES,
MAX_TOTAL_BYTES,
type RejectedFile,
type WorkingEntry,
} from '../working-set';
/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */
export 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',
};
const ACCEPT = [
'.zip',
'.pem',
'.crt',
'.cer',
'.cert',
'.der',
'.ca-bundle',
'.chain',
'.p7b',
'.p7c',
'.pfx',
'.p12',
'.csr',
'.req',
'.txt',
'.key',
].join(',');
const MB = 1024 * 1024;
function roleKey(item: AnyItem): string {
return item.kind === 'certificate' ? item.role : item.kind;
}
function itemName(item: AnyItem): string {
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
}
interface FilesTabProps {
workspace: CertWorkspace;
}
/**
* Reiter „Dateien“: der einzige Ort zum Hinzufuegen (D-01). Jede Auswahl haengt an die Liste an;
* eine zweite Datei ersetzt nie die erste. Pro Eintrag steht, was Tessera darin erkannt hat.
*/
export function FilesTab({ workspace }: FilesTabProps) {
const t = useTranslations('certManager');
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [rejected, setRejected] = useState<RejectedFile[]>([]);
const { entries, analysis, analysisIds, status, errorKey } = workspace;
const add = (list: FileList | File[] | null | undefined) => {
const files = Array.from(list ?? []);
if (files.length === 0) return;
setRejected(workspace.addFiles(files));
};
const itemsOf = (entry: WorkingEntry): AnyItem[] =>
(analysis?.items ?? []).filter((item) =>
item.sources.some((s) => analysisIds[s.file] === entry.id),
);
const ignoredOf = (entry: WorkingEntry) =>
(analysis?.ignored ?? []).filter((i) => analysisIds[i.file] === entry.id);
return (
<div className="space-y-4">
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
add(e.dataTransfer?.files);
}}
className={`flex w-full flex-col items-center gap-1 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dragging ? 'border-primary-strong bg-muted' : 'border-border hover:bg-muted'
}`}
>
<span className="text-sm font-medium text-foreground">{t('files.dropTitle')}</span>
<span className="text-xs text-muted-foreground">{t('files.dropHint')}</span>
</button>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT}
className="hidden"
data-testid="cert-file-input"
tabIndex={-1}
onChange={(e) => {
add(e.target.files);
e.target.value = '';
}}
/>
<p className="text-xs text-muted-foreground">
{t('files.limits', {
maxFiles: MAX_ENTRIES,
maxFile: MAX_FILE_BYTES / MB,
maxTotal: MAX_TOTAL_BYTES / MB,
})}
</p>
{rejected.length > 0 && (
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
<ul className="mt-1 space-y-0.5 text-muted-foreground">
{rejected.map((r) => (
<li key={`${r.name}-${r.reason}`}>
{t(`files.rejected.${r.reason}`, {
name: r.name,
max: MAX_ENTRIES,
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
})}
</li>
))}
</ul>
</div>
)}
<p className="rounded-lg bg-muted p-3 text-xs text-muted-foreground">
{t('files.memoryNote')}
</p>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('files.empty')}</p>
) : (
<section className="space-y-3" aria-label={t('files.listTitle', { count: entries.length })}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-foreground">
{t('files.listTitle', { count: entries.length })}
</h2>
<button
type="button"
onClick={workspace.clear}
className="rounded border border-border px-3 py-1 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('files.removeAll')}
</button>
</div>
{status === 'analyzing' && (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
)}
{status === 'error' && (
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
<p className="text-foreground">{t(`errors.${errorKey ?? 'generic'}`)}</p>
<button
type="button"
onClick={workspace.retry}
className="mt-2 rounded border border-border px-3 py-1 text-foreground hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('files.retry')}
</button>
</div>
)}
<ul className="space-y-2">
{entries.map((entry) => {
const items = itemsOf(entry);
const ignored = ignoredOf(entry);
const analysed = analysisIds.includes(entry.id);
return (
<li key={entry.id} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-all text-sm font-medium text-foreground">{entry.label}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(entry.file.size)}
</p>
</div>
<button
type="button"
onClick={() => workspace.remove(entry.id)}
aria-label={t('files.remove', { name: entry.label })}
className="shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('files.removeShort')}
</button>
</div>
<ul className="mt-2 space-y-1">
{items.map((item) => (
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
<span
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
>
{t(`roles.${roleKey(item)}`)}
</span>
<span className="break-all text-foreground">{itemName(item)}</span>
{item.kind === 'certificate' && item.isExpired && (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
{t('files.expired')}
</span>
)}
</li>
))}
{ignored.map((i) => (
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
{t(`files.ignored.${i.reason}`)}
</li>
))}
{!analysed && status === 'analyzing' && (
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
)}
</ul>
</li>
);
})}
</ul>
</section>
)}
</div>
);
}
@@ -1,144 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { type CertDetails, inspectCertAction } from '../actions';
interface InspectTabProps {
file: File | null;
pemText: string;
password: string;
}
export function InspectTab({ file, pemText, password }: InspectTabProps) {
const t = useTranslations('certManager');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<CertDetails | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleInspect() {
setLoading(true);
setError(null);
setResult(null);
try {
const details = await inspectCertAction({ file, pemText, password });
setResult(details);
} catch (err) {
const msg = err instanceof Error ? err.message.toLowerCase() : '';
if (msg.includes('password') || msg.includes('passwort')) {
setError(t('error.wrongPassword'));
} else if (msg.includes('unknown') || msg.includes('format') || msg.includes('invalid')) {
setError(t('error.unknownFormat'));
} else {
setError(t('error.generic'));
}
} finally {
setLoading(false);
}
}
const canInspect = Boolean(file || pemText);
return (
<div className="space-y-4">
{/* Primary action button */}
<button
type="button"
onClick={handleInspect}
disabled={loading || !canInspect}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.inspect')}
</button>
{/* Error state */}
{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.inspect')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
</div>
)}
{/* Result grid — key-value layout per UI-SPEC (grid-cols-2 gap-2 text-sm) */}
{result && (
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="font-medium text-muted-foreground">Subject CN</span>
<span className="break-all">{result.subject.cn}</span>
{result.subject.o && (
<>
<span className="font-medium text-muted-foreground">Subject O</span>
<span>{result.subject.o}</span>
</>
)}
{result.subject.ou && (
<>
<span className="font-medium text-muted-foreground">Subject OU</span>
<span>{result.subject.ou}</span>
</>
)}
{result.subject.c && (
<>
<span className="font-medium text-muted-foreground">Subject C</span>
<span>{result.subject.c}</span>
</>
)}
<span className="font-medium text-muted-foreground">Issuer CN</span>
<span className="break-all">{result.issuer.cn}</span>
{result.issuer.o && (
<>
<span className="font-medium text-muted-foreground">Issuer O</span>
<span>{result.issuer.o}</span>
</>
)}
<span className="font-medium text-muted-foreground">Not Before</span>
<span>{result.validity.notBefore}</span>
<span className="font-medium text-muted-foreground">Not After</span>
<span>{result.validity.notAfter}</span>
<span className="font-medium text-muted-foreground">Expired</span>
<span>{result.validity.isExpired ? 'Yes' : 'No'}</span>
<span className="font-medium text-muted-foreground">Days Left</span>
<span>{result.validity.daysLeft}</span>
<span className="font-medium text-muted-foreground">Key Type</span>
<span>{result.keyType}</span>
<span className="font-medium text-muted-foreground">Key Bits</span>
<span>{result.keyBits}</span>
<span className="font-medium text-muted-foreground">Serial Number</span>
<span className="break-all">{result.serialNumber}</span>
<span className="font-medium text-muted-foreground">Signature Algorithm</span>
<span>{result.signatureAlgorithm}</span>
<span className="font-medium text-muted-foreground">SHA-1</span>
<span className="break-all font-mono text-xs">{result.fingerprint.sha1}</span>
<span className="font-medium text-muted-foreground">SHA-256</span>
<span className="break-all font-mono text-xs">{result.fingerprint.sha256}</span>
{result.san.length > 0 && (
<>
<span className="font-medium text-muted-foreground">SANs</span>
<span className="break-all">{result.san.join(', ')}</span>
</>
)}
</div>
)}
</div>
);
}
@@ -1,74 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — passthrough t(key) => key (same pattern as stopwatch-widget.test.tsx)
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
// Mock ../actions — MergeTab only needs mergeCertsAction and downloadBase64 to exist;
// this test never triggers the merge action itself, only file-list management.
vi.mock('../actions', () => ({
mergeCertsAction: vi.fn(),
downloadBase64: vi.fn(),
}));
import { MergeTab } from './MergeTab';
afterEach(() => {
vi.restoreAllMocks();
});
describe('MergeTab — Dateiliste', () => {
it('quick-260921-iwr: Entfernen der mittleren Datei laesst genau die erste und dritte Datei uebrig, in dieser Reihenfolge, mit ihren eigenen Namen (belegt den Positionsschluessel bei einer schrumpfenden Liste)', async () => {
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'erste.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'mittlere.pem', { type: 'application/x-pem-file' });
const file3 = new File(['cert3'], 'dritte.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2, file3] } });
// Alle drei Dateien sind zunaechst gelistet
expect(screen.getByText('erste.pem')).toBeInTheDocument();
expect(screen.getByText('mittlere.pem')).toBeInTheDocument();
expect(screen.getByText('dritte.pem')).toBeInTheDocument();
// Die mittlere Datei ueber ihren Entfernen-Knopf loeschen (aria-label enthaelt den Dateinamen)
const removeMiddleBtn = screen.getByLabelText('Remove mittlere.pem');
fireEvent.click(removeMiddleBtn);
// "mittlere.pem" ist verschwunden
expect(screen.queryByText('mittlere.pem')).not.toBeInTheDocument();
// Die erste und dritte Datei stehen weiterhin mit ihren eigenen Namen in der Liste,
// in dieser Reihenfolge — kein Verwechseln durch den Positionsschluessel.
const remainingNames = screen
.getAllByText(/\.pem$/)
.map((el) => el.textContent);
expect(remainingNames).toEqual(['erste.pem', 'dritte.pem']);
// Die dritte Datei behaelt ihren eigenen Entfernen-Knopf (eigener Name im aria-label,
// nicht der der geloeschten mittleren Datei).
expect(screen.getByLabelText('Remove erste.pem')).toBeInTheDocument();
expect(screen.getByLabelText('Remove dritte.pem')).toBeInTheDocument();
expect(screen.queryByLabelText('Remove mittlere.pem')).not.toBeInTheDocument();
});
it('quick-260921-iwr: Entfernen der ersten Datei laesst die zweite an ihrer eigenen Stelle mit eigenem Namen zurueck', async () => {
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'a.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'b.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
fireEvent.click(screen.getByLabelText('Remove a.pem'));
expect(screen.queryByText('a.pem')).not.toBeInTheDocument();
expect(screen.getByText('b.pem')).toBeInTheDocument();
expect(screen.getByLabelText('Remove b.pem')).toBeInTheDocument();
});
});
@@ -1,159 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { mergeCertsAction, downloadBase64 } from '../actions';
type MergeOutputFormat = 'pem' | 'pfx';
interface MergeTabProps {
/** Shared password from page.tsx (shown when PFX output is chosen) */
password: string;
/** Callback to notify page.tsx when output format changes (for shared PasswordField visibility) */
onOutputFormatChange?: (format: string) => void;
}
/**
* MergeTab — multi-file cert merge into PEM chain or password-protected PFX/PKCS12.
*
* Files are managed locally (separate from the shared single-file DropZone in page.tsx).
* Password is shared from page.tsx via prop — the PasswordField becomes visible when
* PFX output is selected (controlled by onOutputFormatChange → page.tsx showPassword).
*
* Security contract (T-09-02): password is never logged, never placed in URLs.
* Security contract (T-09-03): server enforces 20-file max + 5 MB per file.
*/
export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
const t = useTranslations('certManager');
const [files, setFiles] = useState<File[]>([]);
const [outputFormat, setOutputFormat] = useState<MergeOutputFormat>('pem');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleFormatChange(format: MergeOutputFormat) {
setOutputFormat(format);
onOutputFormatChange?.(format);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
// Reset input so the same file can be re-added if needed
e.target.value = '';
}
function removeFile(index: number) {
setFiles((prev) => prev.filter((_, i) => i !== index));
}
async function handleMerge() {
setLoading(true);
setError(null);
try {
const response = await mergeCertsAction(
files,
outputFormat,
outputFormat === 'pfx' ? password : undefined,
);
downloadBase64(response.filename, response.content, response.mimeType);
} catch (err) {
const msg = err instanceof Error ? err.message.toLowerCase() : '';
if (msg.includes('password') || msg.includes('passwort')) {
setError(t('error.wrongPassword'));
} else if (
msg.includes('format') ||
msg.includes('invalid') ||
msg.includes('unknown')
) {
setError(t('error.unknownFormat'));
} else {
setError(t('error.generic'));
}
} finally {
setLoading(false);
}
}
const canMerge = files.length >= 2;
return (
<div className="space-y-4">
{/* Selected file list */}
{files.length > 0 && (
<ul className="space-y-1 rounded border border-border bg-background p-3">
{files.map((f, i) => (
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm">
<span className="text-foreground">{f.name}</span>
<button
type="button"
onClick={() => removeFile(i)}
className="text-xs text-muted-foreground hover:text-destructive ml-2"
aria-label={`Remove ${f.name}`}
>
✕
</button>
</li>
))}
<li className="pt-1 text-xs text-muted-foreground">
{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt
</li>
</ul>
)}
{/* Multi-file input */}
<div>
<input
type="file"
multiple
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
onChange={handleFileChange}
data-testid="merge-file-input"
className="block text-sm text-foreground cursor-pointer"
/>
</div>
{/* Output format selector */}
<div className="flex items-center gap-3">
<label
htmlFor="mergeOutputFormat"
className="text-sm font-medium text-foreground"
>
Ausgabeformat
</label>
<select
id="mergeOutputFormat"
value={outputFormat}
onChange={(e) => handleFormatChange(e.target.value as MergeOutputFormat)}
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="pem">PEM-Kette</option>
<option value="pfx">PFX / PKCS12</option>
</select>
</div>
{/* Primary action button — disabled until >= 2 files selected */}
<button
type="button"
data-testid="merge-action-btn"
onClick={handleMerge}
disabled={loading || !canMerge}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.merge')}
</button>
{/* Error state */}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Empty state — shown when no files and no error */}
{files.length === 0 && !error && (
<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>
);
}
@@ -1,217 +0,0 @@
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 ohne Schluessel im Paket: Passwort eingeben und erneut pruefen', async () => {
mockAnalyze.mockResolvedValueOnce({
items: [{ ...LEAF, matchId: null }],
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('geschuetzte PFX, aber Zertifikat und Schluessel liegen schon vor: ruhiger Hinweis, Passwort optional', async () => {
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: ['a.pfx'], ignored: [] });
await upload([new File(['x'], 'paket.zip')]);
const notice = await screen.findByTestId('locked-notice');
expect(within(notice).getByText(/lockedNotNeeded/)).toBeInTheDocument();
expect(within(notice).queryByLabelText('lockedPassword')).not.toBeInTheDocument();
fireEvent.click(within(notice).getByText('unlockAnyway'));
expect(within(notice).getByLabelText('lockedPassword')).toBeInTheDocument();
});
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();
});
});
@@ -1,402 +0,0 @@
'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);
const [unlockOpen, setUnlockOpen] = useState(false);
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 && (
<LockedNotice
files={result.locked}
// Liegen ein Zertifikat UND sein Schluessel schon einzeln vor,
// steckt in der geschuetzten PFX nichts Neues (Aussteller-ZIP vom
// 01.10.2026: PFX mit unbekanntem Passwort neben .pem/.key) — dann
// nur ein ruhiger Hinweis, das Passwort ist optional.
notNeeded={result.items.some((i) => i.kind === 'certificate' && i.matchId !== null)}
open={unlockOpen}
onOpen={() => setUnlockOpen(true)}
password={password}
onPassword={setPassword}
loading={loading}
onUnlock={() => void analyze(files, password)}
/>
)}
{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 LockedNotice(props: {
files: string[];
notNeeded: boolean;
open: boolean;
onOpen: () => void;
password: string;
onPassword: (value: string) => void;
loading: boolean;
onUnlock: () => void;
}) {
const t = useTranslations('certManager.overview');
const showInput = !props.notNeeded || props.open;
return (
<div
className={`space-y-2 rounded-lg border p-4 ${
props.notNeeded ? 'border-border bg-muted/30' : 'border-status-warn/50 bg-status-warn/10'
}`}
data-testid="locked-notice"
>
<p className="text-sm text-foreground">
{t(props.notNeeded ? 'lockedNotNeeded' : 'locked', { files: props.files.join(', ') })}
</p>
{!showInput && (
<button
type="button"
onClick={props.onOpen}
className="text-xs text-muted-foreground underline"
>
{t('unlockAnyway')}
</button>
)}
{showInput && (
<div className="flex flex-wrap items-center gap-2">
<input
type="password"
value={props.password}
onChange={(e) => props.onPassword(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={!props.password || props.loading}
onClick={props.onUnlock}
>
{t('unlock')}
</button>
</div>
)}
</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>
);
}
@@ -1,79 +0,0 @@
'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 htmlFor="cert-manager-password" className="block text-sm text-foreground">
{t('password.label')}
</label>
<div className="relative flex items-center">
<input
id="cert-manager-password"
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
aria-hidden="true"
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
aria-hidden="true"
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>
);
}
@@ -1,145 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { zipSync } from 'fflate';
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
import { sanitizeZipFilename } from '../zip-filename';
interface SplitTabProps {
file: File | null;
pemText: string;
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',
};
// `downloadAllAsZip` liegt ausserhalb der Komponente und kann den
// Uebersetzungs-Hook nicht selbst aufrufen — der uebersetzte Name kommt
// deshalb als Parameter herein (Restposten 1, quick-260921-jt4). Die
// einzelnen Dateinamen IM Archiv stammen weiterhin unveraendert aus der API.
function downloadAllAsZip(certs: SplitResponse['certs'], zipFilename: 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;
// T-JT4-05: der uebersetzte Name kann Zeichen tragen, die Windows
// verbietet (Umlaute, Sonderzeichen) — sanitizeZipFilename schneidet ihn
// auf das fuer eine Windows-Freigabe Zulaessige zurueck, unabhaengig davon,
// ob der aktuelle Katalogwert zufaellig schon harmlos ist.
a.download = sanitizeZipFilename(zipFilename);
a.click();
URL.revokeObjectURL(url);
}
export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) {
const t = useTranslations('certManager');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<SplitResponse | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSplit() {
if (!file) return;
setLoading(true);
setError(null);
setResult(null);
try {
const response = await splitCertsAction(file);
setResult(response);
} catch (err) {
const msg = err instanceof Error ? err.message.toLowerCase() : '';
if (msg.includes('format') || msg.includes('invalid') || msg.includes('unknown')) {
setError(t('error.unknownFormat'));
} else {
setError(t('error.generic'));
}
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSplit}
disabled={loading || !file}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.split')}
</button>
{result && result.certs.length > 1 && (
<button
type="button"
onClick={() => downloadAllAsZip(result.certs, t('actions.zipFilename'))}
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>}
{!result && !error && (
<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>
)}
{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 gap-3"
>
<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="text-muted-foreground text-xs shrink-0">
{cert.validity.notAfter}
</span>
</div>
<button
type="button"
onClick={() =>
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
}
className="shrink-0 border border-border px-3 py-1 rounded text-xs font-medium hover:bg-background transition-colors"
>
{t('actions.download')}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -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>
);
@@ -0,0 +1,105 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
import {
addFiles as addToSet,
type RejectedFile,
removeEntry,
type WorkingEntry,
} from './working-set';
export type AnalysisStatus = 'idle' | 'analyzing' | 'error';
export interface CertWorkspace {
entries: WorkingEntry[];
/** Antwort der letzten Analyse; null bei leerem Arbeitsbereich */
analysis: AnalysisResult | null;
/** Eintrags-Kennungen zum Zeitpunkt dieser Analyse: Quelle `file` zeigt auf diesen Index */
analysisIds: string[];
status: AnalysisStatus;
/** Schluessel unter certManager.errors.* */
errorKey: string | null;
addFiles: (files: File[]) => RejectedFile[];
remove: (id: string) => void;
clear: () => void;
retry: () => void;
}
/**
* Der gemeinsame Arbeitsbereich (D-01, D-23): lebt nur in diesem Browserfenster. Nach jeder
* Aenderung wird der ganze Bereich neu analysiert; Antworten aelterer Anfragen werden verworfen.
*/
export function useCertWorkspace(): CertWorkspace {
const [entries, setEntriesState] = useState<WorkingEntry[]>([]);
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
const [status, setStatus] = useState<AnalysisStatus>('idle');
const [errorKey, setErrorKey] = useState<string | null>(null);
const entriesRef = useRef<WorkingEntry[]>([]);
const requestCounter = useRef(0);
const idCounter = useRef(0);
const runAnalysis = useCallback((list: WorkingEntry[]) => {
const mine = ++requestCounter.current;
if (list.length === 0) {
setAnalysis(null);
setAnalysisIds([]);
setStatus('idle');
setErrorKey(null);
return;
}
setStatus('analyzing');
setErrorKey(null);
const ids = list.map((e) => e.id);
analyzeWorkingSet(list).then(
(result) => {
if (mine !== requestCounter.current) return;
setAnalysis(result);
setAnalysisIds(ids);
setStatus('idle');
},
(error: unknown) => {
if (mine !== requestCounter.current) return;
setErrorKey(certErrorKey(error));
setStatus('error');
},
);
}, []);
const commit = useCallback(
(next: WorkingEntry[]) => {
entriesRef.current = next;
setEntriesState(next);
runAnalysis(next);
},
[runAnalysis],
);
const addFiles = useCallback(
(files: File[]): RejectedFile[] => {
const result = addToSet(entriesRef.current, files, () => `entry-${++idCounter.current}`);
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
return result.rejected;
},
[commit],
);
const remove = useCallback(
(id: string) => {
commit(removeEntry(entriesRef.current, id));
},
[commit],
);
const clear = useCallback(() => {
commit([]);
}, [commit]);
const retry = useCallback(() => {
runAnalysis(entriesRef.current);
}, [runAnalysis]);
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
addFiles,
MAX_ENTRIES,
MAX_FILE_BYTES,
removeEntry,
toFormData,
type WorkingEntry,
} from './working-set';
let counter = 0;
const nextId = () => `e${++counter}`;
function makeFile(name: string, size = 10, lastModified = 1): File {
const file = new File(['x'.repeat(Math.min(size, 20))], name, { lastModified });
if (size > 20) Object.defineProperty(file, 'size', { value: size });
return file;
}
const NONE: WorkingEntry[] = [];
describe('addFiles', () => {
it('zwei Aufrufe mit je einer Datei ergeben zwei Eintraege in Aufrufreihenfolge', () => {
const first = addFiles(NONE, [makeFile('a.pem')], nextId);
const second = addFiles(first.entries, [makeFile('b.pem')], nextId);
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem']);
expect(second.rejected).toEqual([]);
expect(second.entries[0].origin).toBe('upload');
expect(second.entries[0].password).toBe('');
expect(second.entries[0].host).toBeNull();
});
it('eine zweite Auswahl ersetzt die erste nie', () => {
const first = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pem')], nextId);
const second = addFiles(first.entries, [makeFile('c.pem')], nextId);
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem', 'c.pem']);
});
it('dieselbe Datei (Name, Groesse, Datum) wird als Doppelte abgelehnt', () => {
const first = addFiles(NONE, [makeFile('a.pem', 10, 5)], nextId);
const again = addFiles(first.entries, [makeFile('a.pem', 10, 5)], nextId);
expect(again.entries).toHaveLength(1);
expect(again.rejected).toEqual([{ name: 'a.pem', reason: 'duplicate' }]);
const other = addFiles(first.entries, [makeFile('a.pem', 10, 6)], nextId);
expect(other.entries).toHaveLength(2);
});
it('die 31. Datei wird abgelehnt', () => {
const files = Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => makeFile(`f${i}.pem`, 10, i));
const result = addFiles(NONE, files, nextId);
expect(result.entries).toHaveLength(MAX_ENTRIES);
expect(result.rejected).toEqual([{ name: `f${MAX_ENTRIES}.pem`, reason: 'tooMany' }]);
});
it('eine Datei ueber 5 MB wird abgelehnt', () => {
const result = addFiles(NONE, [makeFile('big.zip', MAX_FILE_BYTES + 1)], nextId);
expect(result.entries).toEqual([]);
expect(result.rejected).toEqual([{ name: 'big.zip', reason: 'tooLarge' }]);
});
it('ein Satz ueber 10 MB gesamt wird abgelehnt', () => {
const four = MAX_FILE_BYTES - 1000;
const first = addFiles(NONE, [makeFile('a.zip', four, 1), makeFile('b.zip', four, 2)], nextId);
expect(first.entries).toHaveLength(2);
const third = addFiles(first.entries, [makeFile('c.zip', four, 3)], nextId);
expect(third.entries).toHaveLength(2);
expect(third.rejected).toEqual([{ name: 'c.zip', reason: 'totalTooLarge' }]);
});
});
describe('removeEntry', () => {
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
const { entries } = addFiles(
NONE,
[makeFile('a.pem'), makeFile('b.pem'), makeFile('c.pem')],
nextId,
);
const after = removeEntry(entries, entries[1].id);
expect(after.map((e) => e.label)).toEqual(['a.pem', 'c.pem']);
});
});
describe('toFormData', () => {
it('haengt die Dateien als `files` in Reihenfolge der Eintraege an', () => {
const { entries } = addFiles(NONE, [makeFile('b.pem'), makeFile('a.pem')], nextId);
const form = toFormData(entries);
const names = form.getAll('files').map((f) => (f as File).name);
expect(names).toEqual(['b.pem', 'a.pem']);
});
});
@@ -0,0 +1,94 @@
/**
* Arbeitsbereich des Zertifikat-Managers (quick-261009-ikt, D-01, D-17, D-23).
*
* Reine Funktionen ohne React: eine Liste von Eintraegen, die nur wachsen oder gezielt
* schrumpfen kann. Eine zweite Auswahl haengt an, sie ersetzt nie die erste.
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters (D-11): nichts davon wird
* gespeichert, auch Passwoerter nicht.
*/
export const MAX_ENTRIES = 30;
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_TOTAL_BYTES = 10 * 1024 * 1024;
export const MAX_PASTE_CHARS = 256_000;
export type EntryOrigin = 'upload' | 'paste' | 'fetched';
export interface WorkingEntry {
id: string;
file: File;
label: string;
origin: EntryOrigin;
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
host: string | null;
/** Passwort fuer diese Datei (ab Task 4); bleibt im Arbeitsspeicher */
password: string;
}
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
export interface RejectedFile {
name: string;
reason: RejectReason;
}
export interface AddFilesResult {
entries: WorkingEntry[];
rejected: RejectedFile[];
}
function sameFile(a: File, b: File): boolean {
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
}
/** Haengt Dateien an. Abgelehnte Dateien stehen mit Grund in `rejected`, die Liste bleibt sonst unveraendert. */
export function addFiles(
entries: WorkingEntry[],
files: File[],
nextId: () => string,
): AddFilesResult {
const next = [...entries];
const rejected: RejectedFile[] = [];
let total = next.reduce((sum, e) => sum + e.file.size, 0);
for (const file of files) {
if (next.some((e) => e.origin === 'upload' && sameFile(e.file, file))) {
rejected.push({ name: file.name, reason: 'duplicate' });
} else if (file.size > MAX_FILE_BYTES) {
rejected.push({ name: file.name, reason: 'tooLarge' });
} else if (next.length >= MAX_ENTRIES) {
rejected.push({ name: file.name, reason: 'tooMany' });
} else if (total + file.size > MAX_TOTAL_BYTES) {
rejected.push({ name: file.name, reason: 'totalTooLarge' });
} else {
next.push({
id: nextId(),
file,
label: file.name,
origin: 'upload',
host: null,
password: '',
});
total += file.size;
}
}
return { entries: next, rejected };
}
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
return entries.filter((e) => e.id !== id);
}
/** Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege. */
export function toFormData(entries: WorkingEntry[]): FormData {
const form = new FormData();
for (const entry of entries) form.append('files', entry.file, entry.file.name);
return form;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace('.', ',')} KB`;
return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
}
+56 -112
View File
@@ -1250,123 +1250,67 @@
},
"certManager": {
"title": "Zertifikat-Manager",
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
"description": "Zertifikate, Zwischenzertifikate, Schlüssel und Anfragen sammeln, prüfen und in das gewünschte Format bringen.",
"tabs": {
"overview": "Übersicht",
"inspect": "Analysieren",
"split": "Aufteilen",
"merge": "Zusammenführen",
"convert": "Konvertieren"
"files": "Dateien",
"filesWithCount": "Dateien ({count})"
},
"dropZone": {
"placeholder": "Datei hierher ziehen oder klicken",
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
"roles": {
"end-entity": "Serverzertifikat",
"intermediate": "Zwischenzertifikat",
"root": "Stammzertifikat",
"privateKey": "Privater Schlüssel",
"csr": "Zertifikatsanfrage (CSR)"
},
"paste": {
"placeholder": "PEM-Inhalt einfügen (-----BEGIN ...)"
},
"password": {
"label": "Passwort (PFX/P12)"
},
"or": "oder",
"actions": {
"inspect": "Analysieren",
"split": "Aufteilen",
"merge": "Zusammenführen",
"convert": "Konvertieren",
"download": "Herunterladen",
"downloadZip": "Alle als ZIP herunterladen",
"processing": "Wird verarbeitet...",
"zipFilename": "Zertifikate.zip"
},
"certRole": {
"root": "Root-CA",
"intermediate": "Zwischen-CA",
"end-entity": "Zertifikat"
},
"emptyState": {
"inspect": "Kein Zertifikat geladen.",
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
"split": "Keine Datei geladen.",
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
"merge": "Keine Zertifikate ausgewählt.",
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
"convert": "Keine Datei geladen.",
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
},
"error": {
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
},
"overview": {
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
"removeFile": "{name} entfernen",
"reset": "Alle entfernen",
"files": {
"dropTitle": "Dateien hierher ziehen oder klicken",
"dropHint": "Sie können mehrere Dateien nacheinander hinzufügen, jede bleibt in der Liste. Tessera erkennt den Inhalt selbst, die Dateiendung spielt keine Rolle.",
"limits": "Bis zu {maxFiles} Dateien, je Datei höchstens {maxFile} MB, zusammen höchstens {maxTotal} MB.",
"memoryNote": "Die Dateien bleiben nur in diesem Browserfenster. Tessera speichert nichts davon; nach dem Neuladen oder Schließen der Seite ist die Liste leer.",
"empty": "Noch keine Dateien. Fügen Sie Ihre Zertifikate hinzu, danach sehen Sie hier, was darin steckt.",
"listTitle": "Ihre Dateien ({count})",
"remove": "„{name}“ entfernen",
"removeShort": "Entfernen",
"removeAll": "Alle entfernen",
"analyzing": "Dateien werden geprüft …",
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
"lockedNotNeeded": "{files} ist mit einem Passwort geschützt. Sie brauchen es nicht: Zertifikat und privater Schlüssel liegen schon als einzelne Dateien vor und sind unten aufgeführt. Eine neue PFX-Datei mit eigenem Passwort können Sie beim Serverzertifikat erstellen.",
"lockedPassword": "Passwort der geschützten Datei",
"unlock": "Entsperren",
"unlockAnyway": "Trotzdem mit Passwort öffnen",
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
"type": {
"end-entity": "Serverzertifikat",
"intermediate": "Zwischenzertifikat",
"root": "Stammzertifikat",
"privateKey": "Privater Schlüssel",
"csr": "Zertifikatsanfrage (CSR)"
"checking": "Wird geprüft …",
"retry": "Erneut versuchen",
"expired": "abgelaufen",
"rejectedTitle": "Nicht hinzugefügt:",
"rejected": {
"duplicate": "„{name}“ ist schon in der Liste.",
"tooMany": "„{name}“: die Liste ist mit {max} Dateien voll.",
"tooLarge": "„{name}“ ist größer als {size} MB.",
"totalTooLarge": "„{name}“: zusammen wären es mehr als {size} MB."
},
"explain": {
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
},
"issuer": "Ausgestellt von",
"validity": "Gültig",
"names": "Gilt für",
"key": "Schlüssel",
"belongsTo": "Gehört zu",
"source": "Gefunden in",
"valid": "Gültig",
"expired": "Abgelaufen",
"expiresSoon": "Läuft in {days} Tagen ab",
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
"downloading": "Wird erstellt …",
"exportError": "Diese Datei konnte nicht erstellt werden.",
"format": {
"crt": "PEM (.crt)",
"cer": "DER (.cer)",
"fullchain": "Mit Kette (.pem)",
"p7b": "PKCS#7 (.p7b)",
"pfx": "PFX (.pfx)",
"key": "PEM (.key)",
"key-rsa": "RSA-PEM (.rsa.key)",
"key-der": "DER (.key.der)",
"csr": "PEM (.csr)",
"csr-der": "DER (.csr.der)"
},
"formatHint": {
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
"cer": "Binärformat, z. B. für Windows und Java",
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
"key": "Schlüssel im Standardformat (PKCS#8)",
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
"key-der": "Schlüssel als Binärdatei",
"csr": "Zertifikatsanfrage als Text",
"csr-der": "Zertifikatsanfrage als Binärdatei"
},
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
"pfxPassword": "Passwort für die PFX-Datei",
"pfxDownload": "PFX herunterladen"
"ignored": {
"unknown": "Darin wurde kein Zertifikat erkannt.",
"nestedZip": "Ein ZIP in einem ZIP wird nicht geöffnet.",
"encryptedZip": "Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.",
"brokenZip": "Das ZIP lässt sich nicht lesen.",
"tooLarge": "Die Datei ist zu groß und wird übersprungen.",
"suspicious": "Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.",
"zipTooLarge": "Das ZIP enthält zu viele Daten und wird nicht geöffnet.",
"tooManyEntries": "Das ZIP enthält zu viele Dateien und wird nicht geöffnet.",
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt."
}
},
"errors": {
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
"invalidInput": "Die Eingabe ist unvollständig oder ungültig. Bitte prüfen Sie Ihre Angaben.",
"notACertificate": "Mindestens eine Angabe ist kein Zertifikat. Bitte prüfen Sie die Dateien.",
"noChain": "Es gibt keine Kette zum Herunterladen. Fügen Sie das Zwischenzertifikat hinzu.",
"keyMissing": "Dafür wird der passende private Schlüssel gebraucht. Fügen Sie ihn im Reiter „Dateien“ hinzu.",
"keyMismatch": "Der Schlüssel gehört nicht zu diesem Zertifikat.",
"passwordRequired": "Bitte geben Sie ein Passwort an.",
"formatNotPossible": "Dieses Format ist für den Schlüsseltyp nicht möglich. Wählen Sie ein anderes Format.",
"templateNeedsKey": "Diese Vorlage braucht das Zertifikat und den passenden privaten Schlüssel.",
"tooLarge": "Die Dateien sind zusammen zu groß. Entfernen Sie einzelne Dateien und versuchen Sie es erneut.",
"aiaMissing": "Im Zertifikat steht keine Adresse, unter der das fehlende Zertifikat zu holen wäre.",
"aiaInternal": "Die Adresse im Zertifikat verweist nicht auf einen öffentlichen Server und wird nicht abgerufen.",
"aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.",
"aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.",
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen."
}
},
"tenderRadar": {
+56 -112
View File
@@ -1250,123 +1250,67 @@
},
"certManager": {
"title": "Certificate Manager",
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
"description": "Collect, check and convert certificates, intermediate certificates, keys and requests into the format you need.",
"tabs": {
"overview": "Overview",
"inspect": "Inspect",
"split": "Split",
"merge": "Merge",
"convert": "Convert"
"files": "Files",
"filesWithCount": "Files ({count})"
},
"dropZone": {
"placeholder": "Drag file here or click to browse",
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
"roles": {
"end-entity": "Server certificate",
"intermediate": "Intermediate certificate",
"root": "Root certificate",
"privateKey": "Private key",
"csr": "Certificate request (CSR)"
},
"paste": {
"placeholder": "Paste PEM content (-----BEGIN ...)"
},
"password": {
"label": "Password (PFX/P12)"
},
"or": "or",
"actions": {
"inspect": "Inspect",
"split": "Split",
"merge": "Merge",
"convert": "Convert",
"download": "Download",
"downloadZip": "Download all as ZIP",
"processing": "Processing...",
"zipFilename": "certificates.zip"
},
"certRole": {
"root": "Root CA",
"intermediate": "Intermediate CA",
"end-entity": "Certificate"
},
"emptyState": {
"inspect": "No certificate loaded.",
"inspectBody": "Upload a file or paste PEM text.",
"split": "No file loaded.",
"splitBody": "Upload a fullchain or P7B file.",
"merge": "No certificates selected.",
"mergeBody": "Upload at least two files.",
"convert": "No file loaded.",
"convertBody": "Upload a file and select an output format."
},
"error": {
"generic": "Processing failed. Check the file format or password.",
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
},
"overview": {
"dropTitle": "Drop certificate files or a ZIP here, or click",
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
"removeFile": "Remove {name}",
"reset": "Remove all",
"files": {
"dropTitle": "Drop files here or click",
"dropHint": "You can add several files one after another, each one stays in the list. Tessera recognises the content itself, the file extension does not matter.",
"limits": "Up to {maxFiles} files, at most {maxFile} MB per file and {maxTotal} MB in total.",
"memoryNote": "The files stay in this browser window only. Tessera stores none of them; after reloading or closing the page the list is empty.",
"empty": "No files yet. Add your certificates and you will see here what they contain.",
"listTitle": "Your files ({count})",
"remove": "Remove \"{name}\"",
"removeShort": "Remove",
"removeAll": "Remove all",
"analyzing": "Checking files …",
"error": "The files could not be checked. Please make sure they are certificate files.",
"locked": "Protected: {files}. Enter the password to read this content as well.",
"lockedNotNeeded": "{files} is password-protected. You do not need it: certificate and private key are already available as separate files and listed below. You can create a new PFX file with your own password at the server certificate.",
"lockedPassword": "Password of the protected file",
"unlock": "Unlock",
"unlockAnyway": "Open with password anyway",
"nothingFound": "No certificate, key or certificate request was found in the files.",
"ignored": "Not used (no certificate detected): {files}",
"type": {
"end-entity": "Server certificate",
"intermediate": "Intermediate certificate",
"root": "Root certificate",
"privateKey": "Private key",
"csr": "Certificate request (CSR)"
"checking": "Checking …",
"retry": "Try again",
"expired": "expired",
"rejectedTitle": "Not added:",
"rejected": {
"duplicate": "\"{name}\" is already in the list.",
"tooMany": "\"{name}\": the list is full with {max} files.",
"tooLarge": "\"{name}\" is larger than {size} MB.",
"totalTooLarge": "\"{name}\": together this would be more than {size} MB."
},
"explain": {
"end-entity": "The actual certificate for your domain – it goes on the web server.",
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
},
"issuer": "Issued by",
"validity": "Valid",
"names": "Valid for",
"key": "Key",
"belongsTo": "Belongs to",
"source": "Found in",
"valid": "Valid",
"expired": "Expired",
"expiresSoon": "Expires in {days} days",
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
"downloading": "Creating …",
"exportError": "This file could not be created.",
"format": {
"crt": "PEM (.crt)",
"cer": "DER (.cer)",
"fullchain": "With chain (.pem)",
"p7b": "PKCS#7 (.p7b)",
"pfx": "PFX (.pfx)",
"key": "PEM (.key)",
"key-rsa": "RSA PEM (.rsa.key)",
"key-der": "DER (.key.der)",
"csr": "PEM (.csr)",
"csr-der": "DER (.csr.der)"
},
"formatHint": {
"crt": "Text format, e.g. for Apache, Nginx and most devices",
"cer": "Binary format, e.g. for Windows and Java",
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
"key": "Key in standard format (PKCS#8)",
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
"key-der": "Key as binary file",
"csr": "Certificate request as text",
"csr-der": "Certificate request as binary file"
},
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
"pfxPassword": "Password for the PFX file",
"pfxDownload": "Download PFX"
"ignored": {
"unknown": "No certificate was recognised in it.",
"nestedZip": "A ZIP inside a ZIP is not opened.",
"encryptedZip": "The ZIP content is password protected and is skipped.",
"brokenZip": "The ZIP cannot be read.",
"tooLarge": "The file is too large and is skipped.",
"suspicious": "The file is packed unusually tightly and is skipped.",
"zipTooLarge": "The ZIP contains too much data and is not opened.",
"tooManyEntries": "The ZIP contains too many files and is not opened.",
"unsupportedKey": "This key type is not supported."
}
},
"errors": {
"generic": "The files could not be checked. Please try again.",
"invalidInput": "The input is incomplete or invalid. Please check your entries.",
"notACertificate": "At least one entry is not a certificate. Please check the files.",
"noChain": "There is no chain to download. Add the intermediate certificate.",
"keyMissing": "This needs the matching private key. Add it in the \"Files\" tab.",
"keyMismatch": "The key does not belong to this certificate.",
"passwordRequired": "Please enter a password.",
"formatNotPossible": "This format is not possible for the key type. Choose another format.",
"templateNeedsKey": "This template needs the certificate and the matching private key.",
"tooLarge": "The files are too large together. Remove some files and try again.",
"aiaMissing": "The certificate names no address from which the missing certificate could be fetched.",
"aiaInternal": "The address in the certificate does not point to a public server and is not contacted.",
"aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.",
"aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.",
"aiaTooLarge": "The issuer's answer is too large and was discarded."
}
},
"tenderRadar": {
@@ -243,4 +243,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Suchergebnisse',
// quick-261009-dkv (Aufgabe 2): Links und Übersichten — korrektes Deutsch mit „ss“
'verlassen',
// quick-261009-ikt: Zertifikat-Manager — korrektes Deutsch mit „ss“
'Schlüsseltyp',
'passende',
'passenden',
'passendes',
'Ausstellers',
];