feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette

- Kettenbau mit Aussteller- und Signaturprüfung (checkIssued plus verify), Wurzel nur auf Wunsch
- build-Route für Fullchain und Nur Kette (PEM), eigene JSON-Grenze 512 KiB mit 413 und Code tooLarge
- Reiter Zusammenführen mit Kettenansicht, Hinweis bei fehlendem Zwischenzertifikat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:03:23 +02:00
parent 75ea83fc01
commit 30118a2401
24 changed files with 1428 additions and 34 deletions
@@ -124,6 +124,38 @@ export interface AnalysisResult {
ignored: IgnoredEntry[];
}
export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr';
/** Anfrage fuer POST build (D-19); die API baut die Reihenfolge immer selbst aus den gesendeten Zertifikaten. */
export interface BuildInput {
content: BuildContent;
format?: string;
certPem?: string;
poolPems?: string[];
keyPem?: string;
csrPem?: string;
includeRoot?: boolean;
includeChain?: boolean;
password?: string;
pfxEncryption?: 'compat' | 'modern';
template?: string;
baseName?: string;
}
export interface BuildFile {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}
export interface BuildResult {
files: BuildFile[];
chainComplete: boolean;
missingIssuerCn: string | null;
snippet?: string | null;
}
/** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */
export class CertManagerRequestError extends Error {
constructor(
@@ -183,6 +215,18 @@ export async function analyzeWorkingSet(entries: WorkingEntry[]): Promise<Analys
return (await response.json()) as AnalysisResult;
}
/** POST /modules/cert-manager/build: JSON, Antwort mit fertigen Dateien (Base64). Passwoerter nur im Koerper. */
export async function buildOutput(input: BuildInput): Promise<BuildResult> {
const response = await fetch(`${API_URL}/modules/cert-manager/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
credentials: 'include',
});
if (!response.ok) throw await errorFromResponse(response);
return (await response.json()) as BuildResult;
}
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
export function downloadBase64(filename: string, content: string, mimeType: string): void {
const bytes = atob(content);
@@ -1,4 +1,11 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -36,13 +43,14 @@ beforeEach(() => {
afterEach(cleanup);
describe('CertManagerPage', () => {
it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => {
it('zeigt Titel und die Reiter „Dateien“ und „Zusammenführen“, „Dateien“ ist aktiv', () => {
render(<CertManagerPage />);
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
const tabs = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current'));
expect(tabs).toHaveLength(1);
expect(tabs[0]).toHaveTextContent('Dateien');
const nav = screen.getByRole('navigation');
const tabs = within(nav).getAllByRole('button');
expect(tabs.map((b) => b.textContent)).toEqual(['Dateien', 'Zusammenführen']);
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
expect(tabs[1]).not.toHaveAttribute('aria-current');
// Startansicht ist der Dateien-Reiter
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
});
@@ -67,4 +75,24 @@ describe('CertManagerPage', () => {
render(<CertManagerPage />);
expect(mockAnalyze).not.toHaveBeenCalled();
});
it('Zusammenführen mit leerem Arbeitsbereich: Hinweis und Knopf zum Reiter „Dateien“', () => {
render(<CertManagerPage />);
fireEvent.click(screen.getByRole('button', { name: 'Zusammenführen' }));
expect(screen.getByText(/Noch keine Dateien\. Laden Sie Ihre Zertifikate/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Zum Reiter „Dateien“' }));
expect(screen.getByText(/Dateien hierher ziehen/)).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'), {
target: { files: [new File(['a'], 'a.pem', { lastModified: 1 })] },
});
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', { name: 'Zusammenführen' }));
expect(screen.getByText('Grundlage: 1 Datei aus dem Reiter „Dateien“.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dateien (1)' }));
expect(screen.getByText('a.pem')).toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
'use client';
import { useFormatter, useTranslations } from 'next-intl';
import type { CertItem, ChainInfo } from '../actions';
import { ROLE_STYLES } from './FilesTab';
interface ChainViewProps {
chain: ChainInfo;
certs: CertItem[];
}
/**
* Eine Kette in Reihenfolge: Serverzertifikat zuerst, dann jeder Aussteller. Eine duenne Linie
* verbindet die Schritte. Fehlt ein Aussteller, steht darunter, was fehlt (D-18).
*/
export function ChainView({ chain, certs }: ChainViewProps) {
const t = useTranslations('certManager');
const format = useFormatter();
const steps = chain.path
.map((id) => certs.find((c) => c.id === id))
.filter((c): c is CertItem => c !== undefined);
return (
<div className="space-y-2">
<ol className="ml-2 space-y-0 border-l border-border">
{steps.map((cert) => (
<li key={cert.id} className="relative space-y-0.5 py-2 pl-4">
<span
aria-hidden="true"
className="absolute -left-[5px] top-3.5 size-2.5 rounded-full border border-border bg-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 text-sm font-medium text-foreground">
{cert.cn || cert.baseName}
</span>
{cert.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>
)}
</div>
<p className="flex flex-wrap gap-x-4 text-xs text-muted-foreground">
<span>
{t('chain.issuedBy', { name: cert.issuerCn || t('chain.unknownIssuer') })}
</span>
<span>
{t('chain.validUntil', {
date: format.dateTime(new Date(cert.notAfter), { dateStyle: 'medium' }),
})}
</span>
</p>
</li>
))}
</ol>
{chain.gap?.kind === 'afterLeaf' && (
<div
role="alert"
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200"
>
<p className="font-medium">
{t('chain.gapAfterLeaf', {
name: chain.gap.missingIssuerCn || t('chain.unknownIssuer'),
})}
</p>
<p className="mt-1 text-xs">{t('chain.gapAfterLeafHint')}</p>
</div>
)}
{chain.gap?.kind === 'afterCa' && (
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
{t('chain.gapAfterCa', { name: chain.gap.missingIssuerCn || t('chain.unknownIssuer') })}
</p>
)}
</div>
);
}
@@ -0,0 +1,20 @@
'use client';
import { useTranslations } from 'next-intl';
interface EmptyWorkspaceProps {
onGoToFiles: () => void;
}
/** Hinweis in allen Reitern ausser „Dateien“, solange der Arbeitsbereich leer ist (D-23). */
export function EmptyWorkspace({ onGoToFiles }: EmptyWorkspaceProps) {
const t = useTranslations('certManager');
return (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('workspace.empty')}</p>
<button type="button" onClick={onGoToFiles} className="btn btn-secondary">
{t('workspace.goToFiles')}
</button>
</div>
);
}
@@ -0,0 +1,219 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
import { CertManagerRequestError } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { MergeTab } from './MergeTab';
const mockBuild = vi.fn();
const mockDownload = vi.fn();
vi.mock('../actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../actions')>();
return {
...actual,
buildOutput: (...args: unknown[]) => mockBuild(...args),
downloadBase64: (...args: unknown[]) => mockDownload(...args),
};
});
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'], issuerCn: string): CertItem {
return {
id,
kind: 'certificate',
role,
sources: [{ file: 0, path: `${id}.pem` }],
pem: `PEM-${id}`,
baseName: cn.replace(/\s/g, '_'),
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: [],
};
}
const leaf = cert('c-leaf', 'www.example.test', 'end-entity', 'Test Inter');
const inter = cert('c-inter', 'Test Inter', 'intermediate', 'Test Root');
const root = cert('c-root', 'Test Root', 'root', 'Test Root');
const leaf2 = cert('c-leaf2', 'api.example.test', 'end-entity', 'Test Inter');
function chain(over: Partial<ChainInfo> & Pick<ChainInfo, 'headId' | 'path'>): ChainInfo {
return { rootId: null, complete: false, gap: null, alternatives: 0, ...over };
}
function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] };
return {
entries: [],
analysis,
analysisIds: [],
status: 'idle',
errorKey: null,
addFiles: () => [],
remove: () => {},
clear: () => {},
retry: () => {},
};
}
const completeChain = chain({
headId: 'c-leaf',
path: ['c-leaf', 'c-inter', 'c-root'],
rootId: 'c-root',
complete: true,
});
beforeEach(() => {
mockBuild.mockReset();
mockDownload.mockReset();
mockBuild.mockResolvedValue({
files: [
{ filename: 'www.example.test-fullchain.pem', content: 'QQ==', mimeType: 'text/plain' },
],
chainComplete: true,
missingIssuerCn: null,
});
});
afterEach(cleanup);
describe('MergeTab', () => {
it('zeigt die Kette in Reihenfolge mit Rollen', () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
const steps = screen.getAllByRole('listitem');
expect(steps).toHaveLength(3);
expect(steps[0]).toHaveTextContent('Serverzertifikat');
expect(steps[0]).toHaveTextContent('www.example.test');
expect(steps[1]).toHaveTextContent('Zwischenzertifikat');
expect(steps[2]).toHaveTextContent('Stammzertifikat');
});
it('bei mehreren Kettenkoepfen: Auswahl per Radio, die Kette wechselt', () => {
const second = chain({ headId: 'c-leaf2', path: ['c-leaf2', 'c-inter'] });
render(<MergeTab workspace={workspace([leaf, leaf2, inter], [completeChain, second])} />);
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(2);
expect(radios[0]).toBeChecked();
fireEvent.click(radios[1]);
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('api.example.test');
});
it('Root-Zertifikat mitnehmen ist zunaechst nicht angehakt', () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).not.toBeChecked();
});
it('ohne Wurzel im Pfad ist das Haekchen gesperrt und der Grund steht da', () => {
const noRoot = chain({
headId: 'c-leaf',
path: ['c-leaf', 'c-inter'],
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
});
render(<MergeTab workspace={workspace([leaf, inter], [noRoot])} />);
expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).toBeDisabled();
expect(screen.getByText(/nicht in Ihren Dateien/)).toBeInTheDocument();
});
it('Fullchain herunterladen: ein Aufruf von buildOutput ohne Wurzel, dann der Download', async () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Fullchain herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(mockBuild).toHaveBeenCalledTimes(1);
expect(mockBuild).toHaveBeenCalledWith({
content: 'fullchain',
format: 'pem',
certPem: 'PEM-c-leaf',
poolPems: ['PEM-c-inter', 'PEM-c-root'],
includeRoot: false,
baseName: 'www.example.test',
});
expect(mockDownload).toHaveBeenCalledWith(
'www.example.test-fullchain.pem',
'QQ==',
'text/plain',
);
});
it('mit angehaktem Root wird includeRoot gesendet', async () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' }));
fireEvent.click(screen.getByRole('button', { name: 'Fullchain herunterladen' }));
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'fullchain', includeRoot: true });
});
it('Nur Kette herunterladen sendet den Inhalt chain', async () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Nur Kette herunterladen' }));
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'chain', includeRoot: false });
});
it('Luecke nach dem Server: „Zwischenzertifikat fehlt“ mit dem Namen', () => {
const gap = chain({
headId: 'c-leaf',
path: ['c-leaf'],
gap: { certId: 'c-leaf', kind: 'afterLeaf', missingIssuerCn: 'Test Inter', aiaUrls: [] },
});
render(<MergeTab workspace={workspace([leaf], [gap])} />);
expect(screen.getByRole('alert')).toHaveTextContent('Zwischenzertifikat fehlt: „Test Inter“');
});
it('Luecke nach einer CA: ruhiger Hinweis, kein Warnfeld', () => {
const gap = chain({
headId: 'c-leaf',
path: ['c-leaf', 'c-inter'],
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
});
render(<MergeTab workspace={workspace([leaf, inter], [gap])} />);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByText(/meist das Stammzertifikat „Test Root“/)).toBeInTheDocument();
});
it('ein Fehlercode der API zeigt den deutschen Text', async () => {
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'noChain'));
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Nur Kette herunterladen' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Es gibt keine Kette');
expect(mockDownload).not.toHaveBeenCalled();
});
it('413 ohne Code zeigt den Text zu tooLarge', async () => {
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(413, null));
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Fullchain herunterladen' }));
expect(await screen.findByRole('alert')).toHaveTextContent('zusammen zu groß');
});
it('ohne erkannte Zertifikate steht ein Hinweis statt der Knoepfe', () => {
render(<MergeTab workspace={workspace([], [])} />);
expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Fullchain herunterladen' })).toBeNull();
});
});
@@ -0,0 +1,151 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
type BuildContent,
buildOutput,
type CertItem,
certErrorKey,
downloadBase64,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { ChainView } from './ChainView';
interface MergeTabProps {
workspace: CertWorkspace;
}
/**
* Reiter „Zusammenführen“: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst
* (die API prueft Aussteller und Signatur) und liefert Fullchain oder Nur Kette. Das
* Stammzertifikat kommt nur mit, wenn es angehakt ist (D-02, Vorgabe aus).
*/
export function MergeTab({ workspace }: MergeTabProps) {
const t = useTranslations('certManager');
const [selectedHead, setSelectedHead] = useState<string | null>(null);
const [includeRoot, setIncludeRoot] = useState(false);
const [busy, setBusy] = useState<BuildContent | null>(null);
const [errorKey, setErrorKey] = useState<string | null>(null);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const chains = workspace.analysis?.chains ?? [];
if (workspace.status === 'analyzing' && !workspace.analysis) {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
if (chains.length === 0) {
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
}
const chain = chains.find((c) => c.headId === selectedHead) ?? chains[0];
const head = certs.find((c) => c.id === chain.headId);
const members = chain.path
.map((id) => certs.find((c) => c.id === id))
.filter((c): c is CertItem => c !== undefined);
const hasRoot = chain.rootId !== null;
const issuersShown = members.filter(
(c) => c.id !== chain.headId && (includeRoot || c.id !== chain.rootId),
);
const download = async (content: 'fullchain' | 'chain') => {
if (!head) return;
setBusy(content);
setErrorKey(null);
try {
const result = await buildOutput({
content,
format: 'pem',
certPem: head.pem,
poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem),
includeRoot,
baseName: head.baseName,
});
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
} catch (error) {
setErrorKey(certErrorKey(error));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t('merge.intro')}</p>
{chains.length > 1 && (
<fieldset className="space-y-1">
<legend className="text-sm font-semibold text-foreground">{t('merge.chooseHead')}</legend>
{chains.map((c) => {
const cert = certs.find((x) => x.id === c.headId);
return (
<label key={c.headId} className="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="cert-chain-head"
checked={c.headId === chain.headId}
onChange={() => setSelectedHead(c.headId)}
/>
<span className="break-all">{cert?.cn || cert?.baseName}</span>
</label>
);
})}
</fieldset>
)}
<ChainView chain={chain} certs={certs} />
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={includeRoot && hasRoot}
disabled={!hasRoot}
onChange={(e) => setIncludeRoot(e.target.checked)}
/>
{t('merge.includeRoot')}
</label>
{!hasRoot && (
<p className="pl-6 text-xs text-muted-foreground">{t('merge.noRootAvailable')}</p>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
disabled={busy !== null}
onClick={() => download('fullchain')}
className="btn btn-primary"
>
{busy === 'fullchain' ? t('merge.busy') : t('merge.downloadFullchain')}
</button>
<button
type="button"
disabled={busy !== null || issuersShown.length === 0}
onClick={() => download('chain')}
className="btn btn-secondary"
>
{busy === 'chain' ? t('merge.busy') : t('merge.downloadChain')}
</button>
</div>
{issuersShown.length === 0 && (
<p className="text-xs text-muted-foreground">{t('merge.chainNeedsIssuer')}</p>
)}
{errorKey && (
<p
role="alert"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t(`errors.${errorKey}`)}
</p>
)}
</div>
);
}
@@ -4,14 +4,16 @@ import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { EmptyWorkspace } from './components/EmptyWorkspace';
import { FilesTab } from './components/FilesTab';
import { MergeTab } from './components/MergeTab';
import { useCertWorkspace } from './use-cert-workspace';
type TabId = 'files';
type TabId = 'files' | 'merge';
/**
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
* Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter (ab Task 2) arbeiten auf dieser Liste.
* Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter arbeiten auf dieser Liste.
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie.
*/
export default function CertManagerPage() {
@@ -25,6 +27,7 @@ export default function CertManagerPage() {
id: 'files',
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
},
{ id: 'merge', label: t('tabs.merge') },
];
return (
@@ -33,6 +36,15 @@ export default function CertManagerPage() {
<TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'files' && <FilesTab workspace={workspace} />}
{activeTab !== 'files' && count === 0 && (
<EmptyWorkspace onGoToFiles={() => setActiveTab('files')} />
)}
{activeTab !== 'files' && count > 0 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('workspace.basis', { count })}</p>
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
</div>
)}
</div>
</div>
);
+26 -1
View File
@@ -1253,7 +1253,8 @@
"description": "Zertifikate, Zwischenzertifikate, Schlüssel und Anfragen sammeln, prüfen und in das gewünschte Format bringen.",
"tabs": {
"files": "Dateien",
"filesWithCount": "Dateien ({count})"
"filesWithCount": "Dateien ({count})",
"merge": "Zusammenführen"
},
"roles": {
"end-entity": "Serverzertifikat",
@@ -1311,6 +1312,30 @@
"aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.",
"aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.",
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen."
},
"workspace": {
"empty": "Noch keine Dateien. Laden Sie Ihre Zertifikate im Reiter „Dateien“ hoch.",
"goToFiles": "Zum Reiter „Dateien“",
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}"
},
"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.",
"chooseHead": "Für welches Zertifikat soll die Kette gebaut werden?",
"includeRoot": "Root-Zertifikat mitnehmen",
"noRootAvailable": "Das Stammzertifikat ist nicht in Ihren Dateien. Die meisten Server brauchen es auch nicht.",
"downloadFullchain": "Fullchain herunterladen",
"downloadChain": "Nur Kette herunterladen",
"busy": "Wird erstellt …",
"chainNeedsIssuer": "Für „Nur Kette“ fehlt ein Zwischenzertifikat. Fügen Sie es im Reiter „Dateien“ hinzu."
},
"chain": {
"issuedBy": "Ausgestellt von {name}",
"validUntil": "Gültig bis {date}",
"unknownIssuer": "unbekannt",
"gapAfterLeaf": "Zwischenzertifikat fehlt: „{name}“",
"gapAfterLeafHint": "Ohne dieses Zertifikat vertrauen manche Geräte dem Server nicht. Fügen Sie es im Reiter „Dateien“ hinzu.",
"gapAfterCa": "Darüber fehlt noch ein Zertifikat, meist das Stammzertifikat „{name}“. Für eine Fullchain ohne Stammzertifikat wird es nicht gebraucht."
}
},
"tenderRadar": {
+26 -1
View File
@@ -1253,7 +1253,8 @@
"description": "Collect, check and convert certificates, intermediate certificates, keys and requests into the format you need.",
"tabs": {
"files": "Files",
"filesWithCount": "Files ({count})"
"filesWithCount": "Files ({count})",
"merge": "Merge"
},
"roles": {
"end-entity": "Server certificate",
@@ -1311,6 +1312,30 @@
"aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.",
"aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.",
"aiaTooLarge": "The issuer's answer is too large and was discarded."
},
"workspace": {
"empty": "No files yet. Upload your certificates in the “Files” tab.",
"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.}}"
},
"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.",
"chooseHead": "For which certificate should the chain be built?",
"includeRoot": "Include root certificate",
"noRootAvailable": "The root certificate is not among your files. Most servers do not need it anyway.",
"downloadFullchain": "Download fullchain",
"downloadChain": "Download chain only",
"busy": "Creating …",
"chainNeedsIssuer": "An intermediate certificate is missing for “chain only”. Add it in the “Files” tab."
},
"chain": {
"issuedBy": "Issued by {name}",
"validUntil": "Valid until {date}",
"unknownIssuer": "unknown",
"gapAfterLeaf": "Intermediate certificate missing: “{name}”",
"gapAfterLeafHint": "Without this certificate some devices will not trust the server. Add it in the “Files” tab.",
"gapAfterCa": "A certificate above this one is missing, usually the root certificate “{name}”. A fullchain without the root does not need it."
}
},
"tenderRadar": {
@@ -249,4 +249,5 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'passenden',
'passendes',
'Ausstellers',
'vertrauen',
];