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:
@@ -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);
|
||||
|
||||
@@ -1254,6 +1254,8 @@
|
||||
"tabs": {
|
||||
"files": "Dateien",
|
||||
"filesWithCount": "Dateien ({count})",
|
||||
"analyze": "Analysieren",
|
||||
"split": "Aufteilen",
|
||||
"merge": "Zusammenführen"
|
||||
},
|
||||
"roles": {
|
||||
@@ -1282,7 +1284,8 @@
|
||||
"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."
|
||||
"totalTooLarge": "„{name}“: zusammen wären es mehr als {size} MB.",
|
||||
"pasteTooLarge": "„{name}“ ist länger als {chars} Zeichen."
|
||||
},
|
||||
"ignored": {
|
||||
"unknown": "Darin wurde kein Zertifikat erkannt.",
|
||||
@@ -1294,7 +1297,13 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"pasteToggle": "PEM-Text einfügen",
|
||||
"pasteHint": "Fügen Sie Text ein, der mit „-----BEGIN“ beginnt. Mehrere Blöcke hintereinander sind möglich.",
|
||||
"pasteLabel": "Eingefügter PEM-Text",
|
||||
"pasteAdd": "Hinzufügen",
|
||||
"pastedLabel": "Eingefügter Text {n}",
|
||||
"originPaste": "Eingefügter Text"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
|
||||
@@ -1318,6 +1327,40 @@
|
||||
"goToFiles": "Zum Reiter „Dateien“",
|
||||
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}"
|
||||
},
|
||||
"analyze": {
|
||||
"intro": "Alles, was in Ihren Dateien erkannt wurde: zuerst die Ketten, dann jedes Zertifikat im Einzelnen. Alle Zeiten stehen in UTC, wie im Zertifikat.",
|
||||
"chainsTitle": "Ketten",
|
||||
"certificatesTitle": "Zertifikate",
|
||||
"ignoredTitle": "Nicht verwendet",
|
||||
"nothingFound": "In den Dateien wurde noch kein Zertifikat erkannt.",
|
||||
"issuer": "Ausgestellt von",
|
||||
"validity": "Gültig",
|
||||
"validRange": "{from} bis {to}",
|
||||
"names": "Gilt für",
|
||||
"key": "Schlüssel",
|
||||
"keyRsa": "RSA, {bits} Bit",
|
||||
"keyEc": "EC, Kurve {curve}",
|
||||
"serial": "Seriennummer",
|
||||
"sha256": "SHA-256",
|
||||
"sha1": "SHA-1",
|
||||
"source": "Gefunden in",
|
||||
"valid": "Gültig",
|
||||
"expired": "Abgelaufen",
|
||||
"expiresSoon": "Läuft in {days} Tagen ab",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"intro": "Jedes erkannte Teil können Sie einzeln in seinem natürlichen Format herunterladen, oder alle zusammen als ZIP.",
|
||||
"nothingFound": "Es wurde noch nichts erkannt, das sich herunterladen ließe.",
|
||||
"download": "Herunterladen",
|
||||
"downloadFor": "„{name}“ herunterladen",
|
||||
"downloadAll": "Alle als ZIP herunterladen",
|
||||
"zipFilename": "Zertifikate.zip"
|
||||
},
|
||||
"merge": {
|
||||
"intro": "Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat selbst und prüft dabei, dass jedes Zertifikat wirklich vom nächsten ausgestellt wurde. Die Reihenfolge Ihrer Dateien spielt keine Rolle.",
|
||||
"noCertificates": "In den Dateien wurde noch kein Zertifikat erkannt. Fügen Sie im Reiter „Dateien“ ein Zertifikat hinzu.",
|
||||
|
||||
@@ -1254,6 +1254,8 @@
|
||||
"tabs": {
|
||||
"files": "Files",
|
||||
"filesWithCount": "Files ({count})",
|
||||
"analyze": "Analyze",
|
||||
"split": "Split",
|
||||
"merge": "Merge"
|
||||
},
|
||||
"roles": {
|
||||
@@ -1282,7 +1284,8 @@
|
||||
"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."
|
||||
"totalTooLarge": "\"{name}\": together this would be more than {size} MB.",
|
||||
"pasteTooLarge": "\"{name}\" is longer than {chars} characters."
|
||||
},
|
||||
"ignored": {
|
||||
"unknown": "No certificate was recognised in it.",
|
||||
@@ -1294,7 +1297,13 @@
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"pasteToggle": "Paste PEM text",
|
||||
"pasteHint": "Paste text that starts with \"-----BEGIN\". Several blocks in a row are fine.",
|
||||
"pasteLabel": "Pasted PEM text",
|
||||
"pasteAdd": "Add",
|
||||
"pastedLabel": "Pasted text {n}",
|
||||
"originPaste": "Pasted text"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "The files could not be checked. Please try again.",
|
||||
@@ -1318,6 +1327,40 @@
|
||||
"goToFiles": "Go to the “Files” tab",
|
||||
"basis": "{count, plural, one {Based on 1 file from the “Files” tab.} other {Based on # files from the “Files” tab.}}"
|
||||
},
|
||||
"analyze": {
|
||||
"intro": "Everything recognised in your files: the chains first, then every certificate in detail. All times are shown in UTC, as in the certificate.",
|
||||
"chainsTitle": "Chains",
|
||||
"certificatesTitle": "Certificates",
|
||||
"ignoredTitle": "Not used",
|
||||
"nothingFound": "No certificate has been recognised in the files yet.",
|
||||
"issuer": "Issued by",
|
||||
"validity": "Valid",
|
||||
"validRange": "{from} to {to}",
|
||||
"names": "Valid for",
|
||||
"key": "Key",
|
||||
"keyRsa": "RSA, {bits} bit",
|
||||
"keyEc": "EC, curve {curve}",
|
||||
"serial": "Serial number",
|
||||
"sha256": "SHA-256",
|
||||
"sha1": "SHA-1",
|
||||
"source": "Found in",
|
||||
"valid": "Valid",
|
||||
"expired": "Expired",
|
||||
"expiresSoon": "Expires in {days} days",
|
||||
"explain": {
|
||||
"end-entity": "The actual certificate for your domain. This one belongs on the web server.",
|
||||
"intermediate": "Vouches for your server certificate towards the browser. It is installed together with the server certificate (chain).",
|
||||
"root": "The top certificate authority. Browsers and operating systems usually have it already."
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"intro": "You can download every recognised part on its own in its natural format, or all of them together as a ZIP.",
|
||||
"nothingFound": "Nothing has been recognised yet that could be downloaded.",
|
||||
"download": "Download",
|
||||
"downloadFor": "Download \"{name}\"",
|
||||
"downloadAll": "Download all as ZIP",
|
||||
"zipFilename": "Certificates.zip"
|
||||
},
|
||||
"merge": {
|
||||
"intro": "Tessera orders the server certificate, intermediate certificates and root certificate itself and checks that each certificate was really issued by the next one. The order of your files does not matter.",
|
||||
"noCertificates": "No certificate has been recognised in the files yet. Add a certificate in the “Files” tab.",
|
||||
|
||||
Reference in New Issue
Block a user