feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen

- ZIP wird an den Anfangsbytes erkannt und mit Grenzen geöffnet (eine Ebene, Verhältnis, Gesamtgröße, verschlüsselte Einträge)
- PKCS#7 als PEM und DER, auch für EC, über den ASN.1-Lauf
- Eingefügter PEM-Text als eigener Eintrag im Reiter Dateien
- Neue Reiter Analysieren und Aufteilen auf dem gemeinsamen Arbeitsbereich

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:15:59 +02:00
parent 30118a2401
commit 1554ae83c1
22 changed files with 1692 additions and 60 deletions
@@ -43,14 +43,19 @@ beforeEach(() => {
afterEach(cleanup);
describe('CertManagerPage', () => {
it('zeigt Titel und die Reiter „Dateien“ und „Zusammenführen“, „Dateien“ ist aktiv', () => {
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen', () => {
render(<CertManagerPage />);
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
const nav = screen.getByRole('navigation');
const tabs = within(nav).getAllByRole('button');
expect(tabs.map((b) => b.textContent)).toEqual(['Dateien', 'Zusammenführen']);
expect(tabs.map((b) => b.textContent)).toEqual([
'Dateien',
'Analysieren',
'Aufteilen',
'Zusammenführen',
]);
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
expect(tabs[1]).not.toHaveAttribute('aria-current');
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
// Startansicht ist der Dateien-Reiter
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
});
@@ -84,6 +89,16 @@ describe('CertManagerPage', () => {
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
});
it.each([
'Analysieren',
'Aufteilen',
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
render(<CertManagerPage />);
fireEvent.click(screen.getByRole('button', { name: tab }));
expect(screen.getByText(/Noch keine Dateien\. Laden Sie Ihre Zertifikate/)).toBeInTheDocument();
expect(screen.queryByTestId('cert-file-input')).not.toBeInTheDocument();
});
it('mit Dateien zeigt Zusammenführen die Grundlage, und die Liste bleibt beim Reiterwechsel', async () => {
render(<CertManagerPage />);
fireEvent.change(screen.getByTestId('cert-file-input'), {
@@ -0,0 +1,231 @@
import { cleanup, render as rtlRender, screen, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import de from '@/messages/de.json';
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import type { WorkingEntry } from '../working-set';
import { AnalyzeTab } from './AnalyzeTab';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function cert(over: Partial<CertItem> & Pick<CertItem, 'id' | 'cn' | 'role'>): CertItem {
return {
kind: 'certificate',
sources: [{ file: 0, path: 'a.pem' }],
pem: `PEM-${over.id}`,
baseName: over.cn,
organization: '',
issuerCn: 'Test Inter',
issuerOrganization: '',
notBefore: '2026-01-01T00:00:00.000Z',
notAfter: '2126-03-15T23:59:59.000Z',
isExpired: false,
daysLeft: 36000,
san: ['www.example.test', 'example.test'],
keyType: 'RSA',
keyBits: 2048,
curve: null,
serialNumber: 'AB12',
sha256: 'AA:BB:CC',
sha1: '11:22:33',
isCa: over.role !== 'end-entity',
selfSigned: over.role === 'root',
aiaIssuerUrls: [],
keyId: null,
csrIds: [],
...over,
};
}
function entry(id: string, name: string, label = name): WorkingEntry {
return {
id,
file: new File(['x'], name),
label,
origin: 'upload',
host: null,
password: '',
};
}
function workspace(
analysis: AnalysisResult | null,
entries: WorkingEntry[] = [],
analysisIds: string[] = [],
): CertWorkspace {
return {
entries,
analysis,
analysisIds,
status: 'idle',
errorKey: null,
addFiles: () => [],
addText: () => null,
remove: () => {},
clear: () => {},
retry: () => {},
};
}
const leaf = cert({
id: 'c-leaf',
cn: 'www.example.test',
role: 'end-entity',
sources: [
{ file: 0, path: 'a.pem' },
{ file: 1, path: 'vendor.zip/kopie/a.pem' },
],
});
const inter = cert({
id: 'c-inter',
cn: 'Test Inter',
role: 'intermediate',
issuerCn: 'Test Root',
sources: [{ file: 1, path: 'vendor.zip/Intermediate/CA.crt' }],
});
const ec = cert({
id: 'c-ec',
cn: 'ec.example.test',
role: 'end-entity',
keyType: 'EC',
keyBits: 256,
curve: 'P-256',
isExpired: true,
daysLeft: -4,
notAfter: '2026-10-05T10:00:00.000Z',
});
const soon = cert({ id: 'c-soon', cn: 'bald.example.test', role: 'end-entity', daysLeft: 12 });
const chains: ChainInfo[] = [
{
headId: 'c-leaf',
path: ['c-leaf', 'c-inter'],
rootId: null,
complete: false,
alternatives: 0,
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
},
{
headId: 'c-ec',
path: ['c-ec'],
rootId: null,
complete: false,
alternatives: 0,
gap: { certId: 'c-ec', kind: 'afterLeaf', missingIssuerCn: 'Inter EC', aiaUrls: [] },
},
];
afterEach(cleanup);
describe('AnalyzeTab', () => {
const analysis: AnalysisResult = {
items: [leaf, ec, soon, inter],
chains,
locked: [],
ignored: [
{ file: 1, path: 'vendor.zip/inner.zip', reason: 'nestedZip' },
{ file: 1, path: 'vendor.zip/readme.txt', reason: 'unknown' },
],
};
const entries = [entry('e1', 'a.pem'), entry('e2', 'vendor.zip')];
it('zeigt zuerst die Ketten, dann die Zertifikate, dann Nicht verwendetes', () => {
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
const headings = screen.getAllByRole('heading').map((h) => h.textContent);
expect(headings).toEqual(['Ketten', 'Zertifikate', 'Nicht verwendet']);
const chainsRegion = screen.getByRole('region', { name: 'Ketten' });
expect(within(chainsRegion).getByText(/Zwischenzertifikat fehlt/)).toBeInTheDocument();
});
it('eine Karte je Zertifikat mit Rolle, Name, Aussteller, Gueltigkeit in UTC, Namen, Schluessel', () => {
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
const cards = screen.getAllByTestId('cert-card');
expect(cards).toHaveLength(4);
const first = cards[0];
expect(within(first).getByText('Serverzertifikat')).toBeInTheDocument();
expect(within(first).getByText('www.example.test')).toBeInTheDocument();
expect(within(first).getByText('Test Inter')).toBeInTheDocument();
expect(within(first).getByText('www.example.test, example.test')).toBeInTheDocument();
expect(within(first).getByText('RSA, 2048 Bit')).toBeInTheDocument();
expect(within(first).getByText('AB12')).toBeInTheDocument();
expect(within(first).getByText('AA:BB:CC')).toBeInTheDocument();
expect(within(first).getByText('11:22:33')).toBeInTheDocument();
// 23:59:59 UTC bleibt der 15. Maerz (nicht der Folgetag in Berlin)
expect(first).toHaveTextContent('15.03.2126');
});
it('EC-Zertifikat nennt die Kurve, abgelaufen und bald ablaufend sind unterscheidbar', () => {
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
const ecCard = screen
.getAllByTestId('cert-card')
.find((c) => c.textContent?.includes('ec.example.test')) as HTMLElement;
expect(within(ecCard).getByText('EC, Kurve P-256')).toBeInTheDocument();
expect(within(ecCard).getByText('Abgelaufen')).toBeInTheDocument();
const soonCard = screen
.getAllByTestId('cert-card')
.find((c) => c.textContent?.includes('bald.example.test')) as HTMLElement;
expect(within(soonCard).getByText('Läuft in 12 Tagen ab')).toBeInTheDocument();
const okCard = screen.getAllByTestId('cert-card')[0];
expect(within(okCard).getByText('Gültig', { selector: 'span' })).toBeInTheDocument();
});
it('nennt die Quellen mit Datei und ZIP-Pfad', () => {
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
const card = screen.getAllByTestId('cert-card')[0];
expect(within(card).getByText('a.pem, vendor.zip/kopie/a.pem')).toBeInTheDocument();
});
it('eingefuegter Text erscheint mit seiner Beschriftung als Quelle', () => {
const pasted = entry('e9', 'pasted-1.pem', 'Eingefügter Text 1');
const a: AnalysisResult = {
items: [
cert({
id: 'c-p',
cn: 'p.example.test',
role: 'end-entity',
sources: [{ file: 0, path: 'pasted-1.pem' }],
}),
],
chains: [],
locked: [],
ignored: [],
};
render(<AnalyzeTab workspace={workspace(a, [pasted], ['e9'])} />);
expect(screen.getByText('Eingefügter Text 1')).toBeInTheDocument();
});
it('listet uebersprungene Eintraege mit Pfad und Grund', () => {
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
const region = screen.getByRole('region', { name: 'Nicht verwendet' });
const items = within(region).getAllByRole('listitem');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('vendor.zip/inner.zip');
expect(items[0]).toHaveTextContent('Ein ZIP in einem ZIP wird nicht geöffnet.');
expect(items[1]).toHaveTextContent('Darin wurde kein Zertifikat erkannt.');
});
it('ohne erkanntes Zertifikat und ohne Ausgelassenes: ruhiger Hinweis', () => {
render(
<AnalyzeTab workspace={workspace({ items: [], chains: [], locked: [], ignored: [] })} />,
);
expect(
screen.getByText('In den Dateien wurde noch kein Zertifikat erkannt.'),
).toBeInTheDocument();
});
it('enthaelt keine Pfeilzeichen und keine ALL-CAPS-Beschriftungen', () => {
const { container } = render(
<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />,
);
expect(container.textContent).not.toMatch(/[→←·]/);
expect(container.innerHTML).not.toContain('uppercase');
});
});
@@ -0,0 +1,76 @@
'use client';
import { useTranslations } from 'next-intl';
import type { CertItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { ChainView } from './ChainView';
import { ItemCard } from './ItemCard';
interface AnalyzeTabProps {
workspace: CertWorkspace;
}
/**
* Reiter „Analysieren“: arbeitet auf dem gemeinsamen Arbeitsbereich (D-01). Zuerst die Ketten
* (eine je Serverzertifikat), dann jedes Zertifikat im Einzelnen, zuletzt, was uebersprungen wurde.
*/
export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
const t = useTranslations('certManager');
const { analysis, entries, analysisIds, status } = workspace;
if (!analysis) {
return status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate');
if (certs.length === 0 && analysis.ignored.length === 0) {
return <p className="text-sm text-muted-foreground">{t('analyze.nothingFound')}</p>;
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">{t('analyze.intro')}</p>
{analysis.chains.length > 0 && (
<section className="space-y-3" aria-label={t('analyze.chainsTitle')}>
<h2 className="text-sm font-semibold text-foreground">{t('analyze.chainsTitle')}</h2>
{analysis.chains.map((chain) => (
<ChainView key={chain.headId} chain={chain} certs={certs} />
))}
</section>
)}
{certs.length > 0 && (
<section className="space-y-3" aria-label={t('analyze.certificatesTitle')}>
<h2 className="text-sm font-semibold text-foreground">
{t('analyze.certificatesTitle')}
</h2>
<ul className="space-y-4">
{certs.map((cert) => (
<ItemCard key={cert.id} cert={cert} entries={entries} analysisIds={analysisIds} />
))}
</ul>
</section>
)}
{analysis.ignored.length > 0 && (
<section className="space-y-2" aria-label={t('analyze.ignoredTitle')}>
<h2 className="text-sm font-semibold text-foreground">{t('analyze.ignoredTitle')}</h2>
<ul className="space-y-1 text-sm text-muted-foreground">
{analysis.ignored.map((i) => (
<li key={`${i.file}-${i.path}-${i.reason}`}>
<span className="break-all text-foreground">{i.path}</span>{' '}
{t(`files.ignored.${i.reason}`)}
</li>
))}
</ul>
</section>
)}
</div>
);
}
@@ -215,4 +215,79 @@ describe('FilesTab', () => {
expect(screen.getByText('Test Inter')).toBeInTheDocument();
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
});
it('ein ZIP zeigt die enthaltenen Teile nach Pfad mit Rolle und Name', async () => {
mockAnalyze.mockResolvedValueOnce({
items: [
cert('c-srv', 'www.example.test', 'end-entity', [0], ['vendor.zip/ServerCertificate.crt']),
cert('c-ca', 'Test Inter', 'intermediate', [0], ['vendor.zip/Intermediate/CA.crt']),
],
chains: [],
locked: [],
ignored: [{ file: 0, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }],
} satisfies AnalysisResult);
render(<Harness />);
selectFiles([makeFile('vendor.zip')]);
const srvPath = await screen.findByText('vendor.zip/ServerCertificate.crt');
const group = srvPath.closest('li') as HTMLElement;
expect(within(group).getByText('www.example.test')).toBeInTheDocument();
expect(within(group).getByText('Serverzertifikat')).toBeInTheDocument();
const caGroup = screen.getByText('vendor.zip/Intermediate/CA.crt').closest('li') as HTMLElement;
expect(within(caGroup).getByText('Zwischenzertifikat')).toBeInTheDocument();
const nested = screen.getByText('vendor.zip/inner.zip').closest('li') as HTMLElement;
expect(
within(nested).getByText('Ein ZIP in einem ZIP wird nicht geöffnet.'),
).toBeInTheDocument();
});
it.each([
['nestedZip', 'Ein ZIP in einem ZIP wird nicht geöffnet.'],
['encryptedZip', 'Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.'],
['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.'],
['brokenZip', 'Das ZIP lässt sich nicht lesen.'],
] as const)('Grund %s wird als Text gezeigt', async (reason, text) => {
mockAnalyze.mockResolvedValueOnce({
items: [],
chains: [],
locked: [],
ignored: [{ file: 0, path: 'v.zip/x', reason }],
} satisfies AnalysisResult);
render(<Harness />);
selectFiles([makeFile('v.zip')]);
expect(await screen.findByText(text)).toBeInTheDocument();
});
it('der eingeklappte Bereich „PEM-Text einfügen“ fügt „Eingefügter Text 1“ als Texteintrag hinzu', async () => {
render(<Harness />);
expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
const area = screen.getByLabelText('Eingefügter PEM-Text');
const add = screen.getByRole('button', { name: 'Hinzufügen' });
expect(add).toBeDisabled();
fireEvent.change(area, { target: { value: '-----BEGIN CERTIFICATE-----\nabc\n' } });
fireEvent.click(add);
expect(await screen.findByText('Eingefügter Text 1')).toBeInTheDocument();
expect(screen.getByText('Eingefügter Text')).toBeInTheDocument();
const sent = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
expect(sent.map((e) => e.file.name)).toEqual(['pasted-1.pem']);
// Bereich ist wieder zu, der naechste Text wird Nummer 2
expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), { target: { value: 'zwei' } });
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
expect(await screen.findByText('Eingefügter Text 2')).toBeInTheDocument();
});
it('zu langer eingefügter Text wird mit Grund abgelehnt', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), {
target: { value: 'a'.repeat(256_001) },
});
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
expect(screen.getByRole('alert')).toHaveTextContent('länger als');
expect(mockAnalyze).not.toHaveBeenCalled();
});
});
@@ -2,12 +2,13 @@
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import type { AnyItem } from '../actions';
import type { AnyItem, IgnoredEntry } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import {
formatBytes,
MAX_ENTRIES,
MAX_FILE_BYTES,
MAX_PASTE_CHARS,
MAX_TOTAL_BYTES,
type RejectedFile,
type WorkingEntry,
@@ -51,6 +52,13 @@ function itemName(item: AnyItem): string {
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
}
/** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */
interface PartGroup {
path: string;
items: AnyItem[];
ignored: IgnoredEntry[];
}
interface FilesTabProps {
workspace: CertWorkspace;
}
@@ -64,6 +72,8 @@ export function FilesTab({ workspace }: FilesTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [rejected, setRejected] = useState<RejectedFile[]>([]);
const [pasteOpen, setPasteOpen] = useState(false);
const [pasteText, setPasteText] = useState('');
const { entries, analysis, analysisIds, status, errorKey } = workspace;
const add = (list: FileList | File[] | null | undefined) => {
@@ -72,12 +82,36 @@ export function FilesTab({ workspace }: FilesTabProps) {
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);
const addPasted = () => {
const problem = workspace.addText(pasteText, (n) => t('files.pastedLabel', { n }));
setRejected(problem ? [problem] : []);
if (!problem) {
setPasteText('');
setPasteOpen(false);
}
};
/** Teile eines Eintrags nach Pfad gruppiert: bei einem ZIP je enthaltene Datei, sonst eine Gruppe. */
const groupsOf = (entry: WorkingEntry): PartGroup[] => {
const groups = new Map<string, PartGroup>();
const group = (path: string): PartGroup => {
let g = groups.get(path);
if (!g) {
g = { path, items: [], ignored: [] };
groups.set(path, g);
}
return g;
};
for (const item of analysis?.items ?? []) {
for (const source of item.sources) {
if (analysisIds[source.file] === entry.id) group(source.path).items.push(item);
}
}
for (const ignored of analysis?.ignored ?? []) {
if (analysisIds[ignored.file] === entry.id) group(ignored.path).ignored.push(ignored);
}
return [...groups.values()].sort((a, b) => a.path.localeCompare(b.path));
};
return (
<div className="space-y-4">
@@ -123,6 +157,40 @@ export function FilesTab({ workspace }: FilesTabProps) {
})}
</p>
<div className="space-y-2">
<button
type="button"
aria-expanded={pasteOpen}
aria-controls="cert-paste-area"
onClick={() => setPasteOpen((open) => !open)}
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.pasteToggle')}
</button>
{pasteOpen && (
<div id="cert-paste-area" className="space-y-2">
<p className="text-xs text-muted-foreground">{t('files.pasteHint')}</p>
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
aria-label={t('files.pasteLabel')}
rows={6}
spellCheck={false}
autoComplete="off"
className="w-full rounded border border-border bg-background px-3 py-2 font-mono text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
onClick={addPasted}
disabled={pasteText.trim() === ''}
className="btn btn-secondary"
>
{t('files.pasteAdd')}
</button>
</div>
)}
</div>
{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>
@@ -133,6 +201,7 @@ export function FilesTab({ workspace }: FilesTabProps) {
name: r.name,
max: MAX_ENTRIES,
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
chars: MAX_PASTE_CHARS,
})}
</li>
))}
@@ -181,16 +250,16 @@ export function FilesTab({ workspace }: FilesTabProps) {
<ul className="space-y-2">
{entries.map((entry) => {
const items = itemsOf(entry);
const ignored = ignoredOf(entry);
const groups = groupsOf(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 className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
<span>{formatBytes(entry.file.size)}</span>
{entry.origin === 'paste' && <span>{t('files.originPaste')}</span>}
</p>
</div>
<button
@@ -202,25 +271,37 @@ export function FilesTab({ workspace }: FilesTabProps) {
{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>
<ul className="mt-2 space-y-2">
{groups.map((g) => (
<li key={g.path}>
{g.path !== entry.file.name && (
<p className="break-all text-xs text-muted-foreground">{g.path}</p>
)}
</li>
))}
{ignored.map((i) => (
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
{t(`files.ignored.${i.reason}`)}
<ul className="space-y-1">
{g.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>
))}
{g.ignored.map((i) => (
<li
key={`${i.path}-${i.reason}`}
className="text-sm text-muted-foreground"
>
{t(`files.ignored.${i.reason}`)}
</li>
))}
</ul>
</li>
))}
{!analysed && status === 'analyzing' && (
@@ -0,0 +1,90 @@
'use client';
import { useFormatter, useTranslations } from 'next-intl';
import type { CertItem, ItemSource } from '../actions';
import type { WorkingEntry } from '../working-set';
import { ROLE_STYLES } from './FilesTab';
interface ItemCardProps {
cert: CertItem;
/** Eintraege des Arbeitsbereichs und ihre Kennungen zum Analysezeitpunkt, fuer die Namen der Quellen */
entries: WorkingEntry[];
analysisIds: string[];
}
/** Anzeigename einer Quelle: Pfad (bei ZIP "zip/eintrag"), bei eingefuegtem Text die Beschriftung. */
function sourceLabel(source: ItemSource, entries: WorkingEntry[], analysisIds: string[]): string {
const entry = entries.find((e) => e.id === analysisIds[source.file]);
if (entry && source.path === entry.file.name) return entry.label;
return source.path;
}
/**
* Eine Karte je Zertifikat im Reiter „Analysieren“ (D-23): Rolle, Name, Gueltigkeit, Namen,
* Schluessel, Seriennummer, Fingerabdruecke und woher das Zertifikat kam.
* Daten kommen in UTC, wie im Zertifikat; der Aussteller nennt dasselbe Datum.
*/
export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
const t = useTranslations('certManager');
const format = useFormatter();
const date = (iso: string) =>
format.dateTime(new Date(iso), { dateStyle: 'medium', timeZone: 'UTC' });
const status = cert.isExpired
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('analyze.expired') }
: cert.daysLeft <= 30
? {
cls: 'bg-status-warn/15 text-status-warn-fg',
text: t('analyze.expiresSoon', { days: cert.daysLeft }),
}
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('analyze.valid') };
const keyText =
cert.keyType === 'RSA'
? t('analyze.keyRsa', { bits: cert.keyBits ?? 0 })
: cert.keyType === 'EC'
? t('analyze.keyEc', { curve: cert.curve ?? '' })
: cert.keyType;
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
return (
<li className="rounded-lg border border-border p-4" data-testid="cert-card">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.role]}`}>
{t(`roles.${cert.role}`)}
</span>
<span className="break-all font-medium text-foreground">{cert.cn || cert.baseName}</span>
<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(`analyze.explain.${cert.role}`)}</p>
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">{t('analyze.issuer')}</dt>
<dd className="break-all text-foreground">{cert.issuerCn || t('chain.unknownIssuer')}</dd>
<dt className="text-muted-foreground">{t('analyze.validity')}</dt>
<dd className="text-foreground">
{t('analyze.validRange', { from: date(cert.notBefore), to: date(cert.notAfter) })}
</dd>
{cert.san.length > 0 && (
<>
<dt className="text-muted-foreground">{t('analyze.names')}</dt>
<dd className="break-all text-foreground">{cert.san.join(', ')}</dd>
</>
)}
<dt className="text-muted-foreground">{t('analyze.key')}</dt>
<dd className="text-foreground">{keyText}</dd>
<dt className="text-muted-foreground">{t('analyze.serial')}</dt>
<dd className="break-all font-mono text-xs text-foreground">{cert.serialNumber}</dd>
<dt className="text-muted-foreground">{t('analyze.sha256')}</dt>
<dd className="break-all font-mono text-xs text-foreground">{cert.sha256}</dd>
<dt className="text-muted-foreground">{t('analyze.sha1')}</dt>
<dd className="break-all font-mono text-xs text-foreground">{cert.sha1}</dd>
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
</dl>
</li>
);
}
@@ -77,6 +77,7 @@ function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
status: 'idle',
errorKey: null,
addFiles: () => [],
addText: () => null,
remove: () => {},
clear: () => {},
retry: () => {},
@@ -0,0 +1,207 @@
import { cleanup, fireEvent, render as rtlRender, screen, 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, AnyItem, CertItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { dedupeNames, SplitTab } from './SplitTab';
const mockDownload = vi.fn();
const mockFetch = vi.fn();
vi.mock('../actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../actions')>();
return { ...actual, downloadBase64: (...args: unknown[]) => mockDownload(...args) };
});
vi.mock('fflate', async (importOriginal) => {
const actual = await importOriginal<typeof import('fflate')>();
return { ...actual, zipSync: vi.fn(actual.zipSync) };
});
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function cert(id: string, cn: string, role: CertItem['role'], baseName = cn): CertItem {
return {
id,
kind: 'certificate',
role,
sources: [{ file: 0, path: `${id}.pem` }],
pem: `-----BEGIN CERTIFICATE-----\n${id}\n-----END CERTIFICATE-----\n`,
baseName,
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: [],
};
}
function workspace(items: AnyItem[] | null): CertWorkspace {
const analysis: AnalysisResult | null = items
? { items, chains: [], locked: [], ignored: [] }
: null;
return {
entries: [],
analysis,
analysisIds: [],
status: 'idle',
errorKey: null,
addFiles: () => [],
addText: () => null,
remove: () => {},
clear: () => {},
retry: () => {},
};
}
beforeEach(() => {
mockDownload.mockReset();
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const items: AnyItem[] = [
cert('c-leaf', 'www.example.test', 'end-entity'),
cert('c-inter', 'Test Inter', 'intermediate', 'Test_Inter'),
];
describe('SplitTab', () => {
it('jedes Teil als Zeile mit Rolle, Name und Dateiname', () => {
render(<SplitTab workspace={workspace(items)} />);
const rows = screen.getAllByRole('listitem');
expect(rows).toHaveLength(2);
expect(within(rows[0]).getByText('Serverzertifikat')).toBeInTheDocument();
expect(within(rows[0]).getByText('www.example.test.crt')).toBeInTheDocument();
expect(within(rows[1]).getByText('Zwischenzertifikat')).toBeInTheDocument();
expect(within(rows[1]).getByText('Test_Inter.crt')).toBeInTheDocument();
});
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', () => {
render(<SplitTab workspace={workspace(items)} />);
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }));
expect(mockDownload).toHaveBeenCalledTimes(1);
const [name, content, mime] = mockDownload.mock.calls[0];
expect(name).toBe('www.example.test.crt');
expect(atob(content)).toBe(items[0].pem);
expect(mime).toBe('application/x-pem-file');
expect(mockFetch).not.toHaveBeenCalled();
});
it('Schluessel und Anfragen bekommen .key und .csr (Zeilen sind unabhaengig von der Art)', () => {
const key: AnyItem = {
id: 'k-1',
kind: 'privateKey',
sources: [],
pem: 'KEY',
baseName: 'schluessel',
keyType: 'RSA',
keyBits: 2048,
curve: null,
wasEncrypted: false,
certIds: [],
};
const csr: AnyItem = {
id: 'r-1',
kind: 'csr',
sources: [],
pem: 'CSR',
baseName: 'anfrage',
cn: 'www.example.test',
organization: '',
san: [],
keyType: 'RSA',
keyBits: 2048,
curve: null,
keyId: null,
certIds: [],
};
render(<SplitTab workspace={workspace([items[0], key, csr])} />);
expect(screen.getByText('schluessel.key')).toBeInTheDocument();
expect(screen.getByText('anfrage.csr')).toBeInTheDocument();
});
it('„Alle als ZIP herunterladen“ packt alle Teile mit durchnummerierten gleichen Namen', async () => {
const fflate = await import('fflate');
const click = vi.fn();
const createObjectURL = vi.fn(() => 'blob:x');
const revoke = vi.fn();
vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL: revoke });
const realCreate = document.createElement.bind(document);
let anchor: HTMLAnchorElement | null = null;
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag);
if (tag === 'a') {
anchor = el as HTMLAnchorElement;
el.click = click;
}
return el;
});
const dup = [
cert('c-1', 'x', 'end-entity', 'gleich'),
cert('c-2', 'y', 'end-entity', 'gleich'),
cert('c-3', 'z', 'root', 'Wurzel'),
];
render(<SplitTab workspace={workspace(dup)} />);
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
expect(fflate.zipSync).toHaveBeenCalledTimes(1);
const files = (fflate.zipSync as unknown as { mock: { calls: [Record<string, Uint8Array>][] } })
.mock.calls[0][0];
expect(Object.keys(files)).toEqual(['gleich.crt', 'gleich-2.crt', 'Wurzel.crt']);
expect(new TextDecoder().decode(files['gleich-2.crt'])).toBe(dup[1].pem);
expect(click).toHaveBeenCalled();
expect((anchor as HTMLAnchorElement | null)?.download).toBe('Zertifikate.zip');
expect(revoke).toHaveBeenCalledWith('blob:x');
expect(mockFetch).not.toHaveBeenCalled();
});
it('bei nur einem Teil gibt es keinen ZIP-Knopf', () => {
render(<SplitTab workspace={workspace([items[0]])} />);
expect(
screen.queryByRole('button', { name: 'Alle als ZIP herunterladen' }),
).not.toBeInTheDocument();
});
it('ohne erkannte Teile: ruhiger Hinweis', () => {
render(<SplitTab workspace={workspace([])} />);
expect(screen.getByText(/noch nichts erkannt/)).toBeInTheDocument();
});
});
describe('dedupeNames', () => {
it('nummeriert wiederholte Namen vor der Endung', () => {
expect(dedupeNames(['a.crt', 'a.crt', 'b.crt', 'a.crt'])).toEqual([
'a.crt',
'a-2.crt',
'b.crt',
'a-3.crt',
]);
});
});
@@ -0,0 +1,124 @@
'use client';
import { zipSync } from 'fflate';
import { useTranslations } from 'next-intl';
import { type AnyItem, downloadBase64 } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { sanitizeZipFilename } from '../zip-filename';
import { ROLE_STYLES } from './FilesTab';
interface SplitTabProps {
workspace: CertWorkspace;
}
const MIME_PEM = 'application/x-pem-file';
/** Dateiname im natuerlichen Format des Teils; Zertifikat .crt, Schluessel .key, Anfrage .csr. */
function fileNameOf(item: AnyItem): string {
const ext = item.kind === 'certificate' ? 'crt' : item.kind === 'privateKey' ? 'key' : 'csr';
return `${item.baseName}.${ext}`;
}
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;
}
function toBase64(text: string): string {
return btoa(text);
}
/** Gleiche Namen bekommen -2, -3 vor der Endung (fflate ueberschriebe sie sonst stillschweigend). */
export function dedupeNames(names: string[]): string[] {
const used = new Set<string>();
return names.map((name) => {
let candidate = name;
let n = 1;
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
while (used.has(candidate)) candidate = `${base}-${++n}${ext}`;
used.add(candidate);
return candidate;
});
}
function downloadZip(items: AnyItem[], zipFilename: string) {
const names = dedupeNames(items.map(fileNameOf));
const files: Record<string, Uint8Array> = {};
items.forEach((item, i) => {
files[names[i]] = new TextEncoder().encode(item.pem);
});
const blob = new Blob([zipSync(files) as BlobPart], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
// Der uebersetzte Name kann Zeichen tragen, die Windows verbietet: auf das Zulaessige zurueckschneiden.
anchor.download = sanitizeZipFilename(zipFilename);
anchor.click();
URL.revokeObjectURL(url);
}
/**
* Reiter „Aufteilen“: jedes erkannte Teil einzeln in seinem natuerlichen Format herunterladen,
* oder alle zusammen als ZIP. Rein im Browser aus den Daten der Analyse; kein Aufruf der API.
*/
export function SplitTab({ workspace }: SplitTabProps) {
const t = useTranslations('certManager');
const items = workspace.analysis?.items ?? [];
if (!workspace.analysis) {
return workspace.status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('split.nothingFound')}</p>;
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('split.intro')}</p>
{items.length > 1 && (
<button
type="button"
onClick={() => downloadZip(items, t('split.zipFilename'))}
className="btn btn-secondary"
>
{t('split.downloadAll')}
</button>
)}
<ul className="space-y-2">
{items.map((item) => (
<li
key={item.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-sm"
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<span
className={`shrink-0 rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
>
{t(`roles.${roleKey(item)}`)}
</span>
<span className="break-all font-medium text-foreground">{itemName(item)}</span>
<span className="break-all text-xs text-muted-foreground">{fileNameOf(item)}</span>
</div>
<button
type="button"
onClick={() => downloadBase64(fileNameOf(item), toBase64(item.pem), MIME_PEM)}
aria-label={t('split.downloadFor', { name: fileNameOf(item) })}
className="shrink-0 rounded border border-border px-3 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('split.download')}
</button>
</li>
))}
</ul>
</div>
);
}
@@ -4,12 +4,14 @@ import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { AnalyzeTab } from './components/AnalyzeTab';
import { EmptyWorkspace } from './components/EmptyWorkspace';
import { FilesTab } from './components/FilesTab';
import { MergeTab } from './components/MergeTab';
import { SplitTab } from './components/SplitTab';
import { useCertWorkspace } from './use-cert-workspace';
type TabId = 'files' | 'merge';
type TabId = 'files' | 'analyze' | 'split' | 'merge';
/**
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
@@ -27,6 +29,8 @@ export default function CertManagerPage() {
id: 'files',
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
},
{ id: 'analyze', label: t('tabs.analyze') },
{ id: 'split', label: t('tabs.split') },
{ id: 'merge', label: t('tabs.merge') },
];
@@ -42,6 +46,8 @@ export default function CertManagerPage() {
{activeTab !== 'files' && count > 0 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('workspace.basis', { count })}</p>
{activeTab === 'analyze' && <AnalyzeTab workspace={workspace} />}
{activeTab === 'split' && <SplitTab workspace={workspace} />}
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
</div>
)}
@@ -3,6 +3,7 @@
import { useCallback, useRef, useState } from 'react';
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
import {
addText as addTextToSet,
addFiles as addToSet,
type RejectedFile,
removeEntry,
@@ -21,6 +22,8 @@ export interface CertWorkspace {
/** Schluessel unter certManager.errors.* */
errorKey: string | null;
addFiles: (files: File[]) => RejectedFile[];
/** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
remove: (id: string) => void;
clear: () => void;
retry: () => void;
@@ -86,6 +89,20 @@ export function useCertWorkspace(): CertWorkspace {
[commit],
);
const addText = useCallback(
(text: string, label?: (n: number) => string): RejectedFile | null => {
const result = addTextToSet(
entriesRef.current,
text,
() => `entry-${++idCounter.current}`,
label,
);
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
return result.rejected;
},
[commit],
);
const remove = useCallback(
(id: string) => {
commit(removeEntry(entriesRef.current, id));
@@ -101,5 +118,16 @@ export function useCertWorkspace(): CertWorkspace {
runAnalysis(entriesRef.current);
}, [runAnalysis]);
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
return {
entries,
analysis,
analysisIds,
status,
errorKey,
addFiles,
addText,
remove,
clear,
retry,
};
}
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
addFiles,
addText,
MAX_ENTRIES,
MAX_FILE_BYTES,
MAX_PASTE_CHARS,
removeEntry,
toFormData,
type WorkingEntry,
@@ -68,6 +70,59 @@ describe('addFiles', () => {
});
});
describe('addText', () => {
it('trimmt, legt pasted-1.pem an und beschriftet „Eingefügter Text 1“', () => {
const r = addText(NONE, ' -----BEGIN CERTIFICATE-----\nabc\n ', nextId);
expect(r.rejected).toBeNull();
expect(r.entries).toHaveLength(1);
const [e] = r.entries;
expect(e.file.name).toBe('pasted-1.pem');
expect(e.label).toBe('Eingefügter Text 1');
expect(e.origin).toBe('paste');
expect(e.host).toBeNull();
expect(e.password).toBe('');
});
it('der Inhalt der Datei ist der getrimmte Text', async () => {
const r = addText(NONE, '\n hallo \n', nextId);
expect(await r.entries[0].file.text()).toBe('hallo');
});
it('der naechste Text heisst pasted-2.pem, auch nach dem Entfernen des ersten', () => {
const one = addText(NONE, 'eins', nextId);
const two = addText(one.entries, 'zwei', nextId);
expect(two.entries.map((e) => e.file.name)).toEqual(['pasted-1.pem', 'pasted-2.pem']);
const without = removeEntry(two.entries, two.entries[0].id);
const three = addText(without, 'drei', nextId);
expect(three.entries.map((e) => e.file.name)).toEqual(['pasted-2.pem', 'pasted-3.pem']);
});
it('die Beschriftung kommt aus der uebergebenen Funktion (Sprache)', () => {
const r = addText(NONE, 'x', nextId, (n) => `Pasted text ${n}`);
expect(r.entries[0].label).toBe('Pasted text 1');
});
it('leerer oder nur aus Leerraum bestehender Text aendert nichts', () => {
const r = addText(NONE, ' \n ', nextId);
expect(r.entries).toEqual([]);
expect(r.rejected).toBeNull();
});
it('mehr als 256 000 Zeichen werden abgelehnt', () => {
const r = addText(NONE, 'a'.repeat(MAX_PASTE_CHARS + 1), nextId);
expect(r.entries).toEqual([]);
expect(r.rejected).toEqual({ name: 'Eingefügter Text 1', reason: 'pasteTooLarge' });
});
it('eingefuegte Texte zaehlen zu den 30 Eintraegen', () => {
const files = Array.from({ length: MAX_ENTRIES }, (_, i) => makeFile(`f${i}.pem`, 10, i));
const full = addFiles(NONE, files, nextId);
const r = addText(full.entries, 'noch einer', nextId);
expect(r.entries).toHaveLength(MAX_ENTRIES);
expect(r.rejected).toEqual({ name: 'Eingefügter Text 1', reason: 'tooMany' });
});
});
describe('removeEntry', () => {
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
const { entries } = addFiles(
@@ -25,7 +25,7 @@ export interface WorkingEntry {
password: string;
}
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge' | 'pasteTooLarge';
export interface RejectedFile {
name: string;
@@ -75,6 +75,53 @@ export function addFiles(
return { entries: next, rejected };
}
const PASTED_NAME = /^pasted-(\d+)\.pem$/;
export interface AddTextResult {
entries: WorkingEntry[];
rejected: RejectedFile | null;
}
/**
* Haengt eingefuegten PEM-Text als eigenen Eintrag an (D-23): `pasted-<n>.pem`, Beschriftung
* „Eingefuegter Text <n>“. Die Nummer zaehlt weiter, auch wenn ein frueherer Text entfernt wurde.
* Leerer Text aendert nichts. Der Text zaehlt wie eine Datei zu den 30 Eintraegen und zur Gesamtgroesse.
*/
export function addText(
entries: WorkingEntry[],
text: string,
nextId: () => string,
labelFor: (n: number) => string = (n) => `Eingefügter Text ${n}`,
): AddTextResult {
const trimmed = text.trim();
if (trimmed === '') return { entries, rejected: null };
const used = entries
.filter((e) => e.origin === 'paste')
.map((e) => Number(PASTED_NAME.exec(e.file.name)?.[1] ?? 0));
const n = Math.max(0, ...used) + 1;
const label = labelFor(n);
const file = new File([trimmed], `pasted-${n}.pem`, {
type: 'text/plain',
lastModified: Date.now(),
});
if (trimmed.length > MAX_PASTE_CHARS) {
return { entries, rejected: { name: label, reason: 'pasteTooLarge' } };
}
if (entries.length >= MAX_ENTRIES) {
return { entries, rejected: { name: label, reason: 'tooMany' } };
}
const total = entries.reduce((sum, e) => sum + e.file.size, 0);
if (total + file.size > MAX_TOTAL_BYTES) {
return { entries, rejected: { name: label, reason: 'totalTooLarge' } };
}
return {
entries: [...entries, { id: nextId(), file, label, origin: 'paste', host: null, password: '' }],
rejected: null,
};
}
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
return entries.filter((e) => e.id !== id);