feat(cert-manager): Vorlagen für Zielsysteme, Modulversion 1.2.0 und Anleitungen

- Sieben Vorlagen (Nginx, Apache ab/vor 2.4.8, Windows/IIS, Nginx Proxy Manager, HAProxy, Tomcat) mit Dateien und Einrichtungszeilen
- Reiter „Vorlagen“ mit ZIP samt Anleitung, Schnipsel und Kopieren
- Modulversion 1.2.0, Modul-Changelog, CHANGELOG und drei Anleitungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:57:48 +02:00
parent 65a1dca80f
commit 47b26219b2
19 changed files with 1434 additions and 14 deletions
@@ -124,7 +124,25 @@ export interface AnalysisResult {
ignored: IgnoredEntry[];
}
export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr';
export type BuildContent =
| 'leaf'
| 'fullchain'
| 'chain'
| 'leafKey'
| 'pfx'
| 'key'
| 'csr'
| 'template';
/** Kennungen der Vorlagen (Zielsysteme); die API prueft sie gegen dieselbe Liste. */
export type TemplateId =
| 'nginx'
| 'apache'
| 'apache-legacy'
| 'iis'
| 'npm'
| 'haproxy'
| 'tomcat';
/** Anfrage fuer POST build (D-19); die API baut die Reihenfolge immer selbst aus den gesendeten Zertifikaten. */
export interface BuildInput {
@@ -138,7 +156,7 @@ export interface BuildInput {
includeChain?: boolean;
password?: string;
pfxEncryption?: 'compat' | 'modern';
template?: string;
template?: TemplateId;
baseName?: string;
}
@@ -43,7 +43,7 @@ beforeEach(() => {
afterEach(cleanup);
describe('CertManagerPage', () => {
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren', () => {
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren, Vorlagen', () => {
render(<CertManagerPage />);
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
const nav = screen.getByRole('navigation');
@@ -54,6 +54,7 @@ describe('CertManagerPage', () => {
'Aufteilen',
'Zusammenführen',
'Konvertieren',
'Vorlagen',
]);
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
@@ -94,6 +95,7 @@ describe('CertManagerPage', () => {
'Analysieren',
'Aufteilen',
'Konvertieren',
'Vorlagen',
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
render(<CertManagerPage />);
fireEvent.click(screen.getByRole('button', { name: tab }));
@@ -0,0 +1,298 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { strFromU8, unzipSync } from 'fflate';
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, KeyItem } from '../actions';
import { CertManagerRequestError } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { TemplatesTab } from './TemplatesTab';
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 keyedLeaf = { ...leaf, keyId: 'k-leaf' };
const inter = cert('c-inter', 'Test Inter', 'intermediate', 'Test Root');
const root = cert('c-root', 'Test Root', 'root', 'Test Root');
const key: KeyItem = {
id: 'k-leaf',
kind: 'privateKey',
sources: [{ file: 1, path: 'key.pem' }],
pem: 'PEM-KEY',
baseName: 'schluessel',
keyType: 'RSA',
keyBits: 2048,
curve: null,
wasEncrypted: false,
certIds: ['c-leaf'],
};
const completeChain: ChainInfo = {
headId: 'c-leaf',
path: ['c-leaf', 'c-inter', 'c-root'],
rootId: 'c-root',
complete: true,
gap: null,
alternatives: 0,
};
function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWorkspace {
const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] };
return {
entries: [],
analysis,
analysisIds: [],
status: 'idle',
errorKey: null,
addFiles: () => [],
addText: () => null,
setPassword: () => {},
remove: () => {},
clear: () => {},
retry: () => {},
};
}
const withKey = () => workspace([keyedLeaf, inter, root, key], [completeChain]);
const b64 = (text: string) => btoa(text);
function nginxResult() {
return {
files: [
{ filename: 'fullchain.pem', content: b64('FULLCHAIN'), mimeType: 'application/x-pem-file' },
{ filename: 'privkey.pem', content: b64('PRIVKEY'), mimeType: 'application/x-pem-file' },
],
chainComplete: true,
missingIssuerCn: null,
snippet: 'ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;',
};
}
beforeEach(() => {
mockBuild.mockReset();
mockDownload.mockReset();
mockBuild.mockResolvedValue(nginxResult());
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('TemplatesTab', () => {
it('zeigt sieben Karten in fester Reihenfolge', () => {
render(<TemplatesTab workspace={withKey()} />);
const titles = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
expect(titles).toEqual([
'Nginx',
'Apache 2.4.8 und neuer',
'Apache älter als 2.4.8',
'Windows / IIS',
'Nginx Proxy Manager',
'HAProxy',
'Tomcat / Java',
]);
expect(screen.getAllByText(/Das erhalten Sie/)).toHaveLength(7);
});
it('der Haken „Root-Zertifikat mitnehmen“ ist zunaechst aus', () => {
render(<TemplatesTab workspace={withKey()} />);
expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).not.toBeChecked();
});
it('ohne passenden Schluessel sind alle Knoepfe gesperrt und der Grund steht da', () => {
render(<TemplatesTab workspace={workspace([leaf, inter, root], [completeChain])} />);
expect(screen.getByText(/noch kein passender privater Schlüssel/)).toBeInTheDocument();
for (const button of screen.getAllByRole('button', { name: /^Vorlage herunterladen/ })) {
expect(button).toBeDisabled();
}
});
it('ohne erkannte Zertifikate steht ein Hinweis statt der Karten', () => {
render(<TemplatesTab workspace={workspace([], [])} />);
expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument();
expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0);
});
it('Nginx: eine Anfrage mit Vorlage und Schluessel, Ergebnis als ZIP mit Anleitung', async () => {
render(<TemplatesTab workspace={withKey()} />);
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(mockBuild).toHaveBeenCalledWith({
content: 'template',
template: 'nginx',
certPem: 'PEM-c-leaf',
poolPems: ['PEM-c-inter', 'PEM-c-root'],
keyPem: 'PEM-KEY',
includeRoot: false,
baseName: 'www.example.test',
});
const [name, content, mime] = mockDownload.mock.calls[0];
expect(name).toBe('www.example.test-nginx.zip');
expect(mime).toBe('application/zip');
const bytes = Uint8Array.from(atob(content as string), (c) => c.charCodeAt(0));
const files = unzipSync(bytes);
expect(Object.keys(files).sort()).toEqual(['ANLEITUNG.txt', 'fullchain.pem', 'privkey.pem']);
expect(strFromU8(files['fullchain.pem'])).toBe('FULLCHAIN');
const guide = strFromU8(files['ANLEITUNG.txt']);
expect(guide).toContain('Anleitung für Nginx');
expect(guide).toContain('1. Kopieren Sie beide Dateien');
expect(guide).toContain('ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;');
});
it('mit angehaktem Root wird includeRoot gesendet', async () => {
render(<TemplatesTab workspace={withKey()} />);
fireEvent.click(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' }));
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
expect(mockBuild.mock.calls[0][0]).toMatchObject({ includeRoot: true });
});
it('danach steht der Schnipsel da, und Kopieren schreibt ihn in die Zwischenablage', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
render(<TemplatesTab workspace={withKey()} />);
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
expect(await screen.findByText(/^ssl_certificate \/etc\/nginx/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Kopieren' }));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(
'ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;',
),
);
expect(await screen.findByRole('button', { name: 'Kopiert' })).toBeInTheDocument();
});
it('HAProxy: eine Datei wird direkt geladen, ohne ZIP', async () => {
mockBuild.mockResolvedValueOnce({
files: [{ filename: 'www.example.test.pem', content: b64('X'), mimeType: 'text/plain' }],
chainComplete: true,
missingIssuerCn: null,
snippet: 'bind :443 ssl crt /etc/haproxy/certs/www.example.test.pem',
});
render(<TemplatesTab workspace={withKey()} />);
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: HAProxy' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(mockDownload).toHaveBeenCalledWith('www.example.test.pem', b64('X'), 'text/plain');
expect(await screen.findByText(/^bind :443 ssl crt/)).toBeInTheDocument();
});
it('Nginx Proxy Manager: ZIP ohne Schnipsel, die Schritte nennen Custom und die Felder', async () => {
mockBuild.mockResolvedValueOnce({
files: [
{ filename: 'certificate.pem', content: b64('C'), mimeType: 'text/plain' },
{ filename: 'privkey.pem', content: b64('K'), mimeType: 'text/plain' },
],
chainComplete: true,
missingIssuerCn: null,
snippet: null,
});
render(<TemplatesTab workspace={withKey()} />);
expect(screen.getByText(/Wählen Sie „Custom“/)).toBeInTheDocument();
expect(screen.getByText(/bei „Certificate Key“ die Datei privkey\.pem/)).toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx Proxy Manager' }),
);
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(mockDownload.mock.calls[0][0]).toBe('www.example.test-npm.zip');
expect(screen.queryByText('Zeilen für die Einrichtung')).not.toBeInTheDocument();
});
it('IIS: Kompatibel ist vorgewaehlt, ohne Passwort gesperrt, dann Anfrage mit Passwort', async () => {
mockBuild.mockResolvedValueOnce({
files: [
{ filename: 'www.example.test.pfx', content: b64('P'), mimeType: 'application/x-pkcs12' },
],
chainComplete: true,
missingIssuerCn: null,
snippet: 'Import-PfxCertificate -FilePath .\\www.example.test.pfx',
});
render(<TemplatesTab workspace={withKey()} />);
const encryptions = screen.getAllByRole('combobox', { name: 'Verschlüsselung' });
expect(encryptions).toHaveLength(2); // IIS und Tomcat
expect((encryptions[0] as HTMLSelectElement).value).toBe('compat');
const button = screen.getByRole('button', { name: 'Vorlage herunterladen: Windows / IIS' });
expect(button).toBeDisabled();
const passwords = screen.getAllByLabelText('PFX-Passwort');
const repeats = screen.getAllByLabelText('Passwort wiederholen');
fireEvent.change(passwords[0], { target: { value: 'Neu-Pass-2026' } });
fireEvent.change(repeats[0], { target: { value: 'Neu-Pass-2026' } });
expect(button).toBeEnabled();
// Tomcat bleibt gesperrt: eigenes Passwort je Karte
expect(
screen.getByRole('button', { name: 'Vorlage herunterladen: Tomcat / Java' }),
).toBeDisabled();
fireEvent.click(button);
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
expect(mockBuild.mock.calls[0][0]).toMatchObject({
content: 'template',
template: 'iis',
password: 'Neu-Pass-2026',
pfxEncryption: 'compat',
});
expect(mockDownload).toHaveBeenCalledWith(
'www.example.test.pfx',
b64('P'),
'application/x-pkcs12',
);
// das Passwort steht nirgends im dargestellten Text
expect(document.body.textContent).not.toContain('Neu-Pass-2026');
});
it('ein Fehlercode der API zeigt den deutschen Text', async () => {
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'keyMismatch'));
render(<TemplatesTab workspace={withKey()} />);
fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Der Schlüssel gehört nicht');
expect(mockDownload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,320 @@
'use client';
import { strFromU8, strToU8, zipSync } from 'fflate';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
type BuildFile,
buildOutput,
type CertItem,
certErrorKey,
downloadBase64,
type KeyItem,
type TemplateId,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { sanitizeZipFilename } from '../zip-filename';
import { ChainView } from './ChainView';
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
interface TemplatesTabProps {
workspace: CertWorkspace;
}
/** Reihenfolge der Karten; `messageKey` ist der Zweig unter certManager.templates, `steps` die Zahl der Schritte. */
const TEMPLATES: { id: TemplateId; messageKey: string; steps: number; pfx: boolean }[] = [
{ id: 'nginx', messageKey: 'nginx', steps: 3, pfx: false },
{ id: 'apache', messageKey: 'apache', steps: 3, pfx: false },
{ id: 'apache-legacy', messageKey: 'apacheLegacy', steps: 3, pfx: false },
{ id: 'iis', messageKey: 'iis', steps: 3, pfx: true },
{ id: 'npm', messageKey: 'npm', steps: 3, pfx: false },
{ id: 'haproxy', messageKey: 'haproxy', steps: 3, pfx: false },
{ id: 'tomcat', messageKey: 'tomcat', steps: 3, pfx: true },
];
interface PfxChoice {
password: string;
encryption: PfxEncryption;
}
function bytesOf(file: BuildFile): Uint8Array {
const raw = atob(file.content);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
}
/** Mehrere Dateien: ZIP aus den Dateien der API plus der Anleitung als Textdatei. */
function zipBase64(files: BuildFile[], instructionsName: string, instructions: string): string {
const entries: Record<string, Uint8Array> = {};
for (const file of files) entries[file.filename] = bytesOf(file);
entries[instructionsName] = strToU8(instructions);
return btoa(strFromU8(zipSync(entries), true));
}
/**
* Reiter „Vorlagen“: ein Klick liefert die Dateien fuer ein Zielsystem (Nginx, Apache, Windows/IIS,
* Nginx Proxy Manager, HAProxy, Tomcat) und zeigt die Zeilen fuer die Einrichtung. Alle Vorlagen
* brauchen das Serverzertifikat und den passenden privaten Schluessel. Die API baut die Dateien
* (und ordnet die Kette selbst); mehrere Dateien packt der Browser mit einer Anleitung in eine ZIP-Datei.
* PFX-Passwoerter bleiben im Zustand der Karte und gehen nur in die Anfrage.
*/
export function TemplatesTab({ workspace }: TemplatesTabProps) {
const t = useTranslations('certManager');
const [selectedHead, setSelectedHead] = useState<string | null>(null);
const [includeRoot, setIncludeRoot] = useState(false);
const [busy, setBusy] = useState<TemplateId | null>(null);
const [errorKey, setErrorKey] = useState<string | null>(null);
const [shown, setShown] = useState<{ id: TemplateId; snippet: string } | null>(null);
const [copied, setCopied] = useState(false);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const keys = (workspace.analysis?.items ?? []).filter(
(i): i is KeyItem => i.kind === 'privateKey',
);
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 key = head?.keyId ? keys.find((k) => k.id === head.keyId) : undefined;
const instructionsText = (
template: (typeof TEMPLATES)[number],
files: BuildFile[],
snippet: string,
) => {
const lines = [
t('templates.instructionsHeading', { name: t(`templates.${template.messageKey}.title`) }),
'',
];
lines.push(`${t('templates.instructionsFiles')}:`);
for (const file of files) lines.push(`- ${file.filename}`);
lines.push('', `${t('templates.stepsTitle')}:`);
for (let n = 1; n <= template.steps; n++) {
lines.push(`${n}. ${t(`templates.${template.messageKey}.steps.${n}`)}`);
}
if (snippet) lines.push('', `${t('templates.instructionsSnippet')}:`, snippet);
return `${lines.join('\n')}\n`;
};
const download = async (template: (typeof TEMPLATES)[number], pfx: PfxChoice | null) => {
if (!head || !key) return;
setBusy(template.id);
setErrorKey(null);
setShown(null);
setCopied(false);
try {
const result = await buildOutput({
content: 'template',
template: template.id,
certPem: head.pem,
poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem),
keyPem: key.pem,
includeRoot,
baseName: head.baseName,
...(pfx ? { password: pfx.password, pfxEncryption: pfx.encryption } : {}),
});
const snippet = result.snippet ?? '';
if (result.files.length > 1) {
const zipName = sanitizeZipFilename(`${head.baseName}-${template.id}.zip`);
downloadBase64(
zipName,
zipBase64(
result.files,
t('templates.instructionsFile'),
instructionsText(template, result.files, snippet),
),
'application/zip',
);
} else {
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
}
if (snippet) setShown({ id: template.id, snippet });
} catch (error) {
setErrorKey(certErrorKey(error));
} finally {
setBusy(null);
}
};
const copy = async (snippet: string) => {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
} catch {
setCopied(false);
}
};
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t('templates.intro')}</p>
{chains.length > 1 && (
<fieldset className="space-y-1">
<legend className="text-sm font-semibold text-foreground">
{t('templates.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-template-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>
{!key && (
<p
role="status"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t('templates.needsKey')}
</p>
)}
<ul className="space-y-3">
{TEMPLATES.map((template) => (
<TemplateCard
key={template.id}
template={template}
disabled={!key || busy !== null}
busy={busy === template.id}
onDownload={(pfx) => download(template, pfx)}
snippet={shown?.id === template.id ? shown.snippet : null}
copied={copied}
onCopy={copy}
/>
))}
</ul>
{errorKey && (
<p
role="alert"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t(`errors.${errorKey}`)}
</p>
)}
</div>
);
}
interface TemplateCardProps {
template: (typeof TEMPLATES)[number];
disabled: boolean;
busy: boolean;
onDownload: (pfx: PfxChoice | null) => void;
snippet: string | null;
copied: boolean;
onCopy: (snippet: string) => void;
}
/** Eine Vorlage: Titel, was geliefert wird, die Schritte, bei PFX-Vorlagen Passwort und Verschluesselung. */
function TemplateCard({
template,
disabled,
busy,
onDownload,
snippet,
copied,
onCopy,
}: TemplateCardProps) {
const t = useTranslations('certManager');
const [password, setPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [encryption, setEncryption] = useState<PfxEncryption>('compat');
const base = `templates.${template.messageKey}`;
const ready = !template.pfx || pfxPasswordReady(password, repeat);
return (
<li className="space-y-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-semibold text-foreground">{t(`${base}.title`)}</h3>
<p className="text-sm text-foreground">
<span className="font-medium">{t('templates.delivers')}: </span>
{t(`${base}.delivers`)}
</p>
<div className="space-y-1">
<p className="text-xs font-semibold text-foreground">{t('templates.stepsTitle')}</p>
<ol className="list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground">
{Array.from({ length: template.steps }, (_, i) => i + 1).map((n) => (
<li key={n}>{t(`${base}.steps.${n}`)}</li>
))}
</ol>
</div>
{template.pfx && (
<PfxOptions
password={password}
repeat={repeat}
encryption={encryption}
onPassword={setPassword}
onRepeat={setRepeat}
onEncryption={setEncryption}
/>
)}
<button
type="button"
disabled={disabled || !ready}
onClick={() => onDownload(template.pfx ? { password, encryption } : null)}
aria-label={`${t('templates.download')}: ${t(`${base}.title`)}`}
className="btn btn-primary"
>
{busy ? t('templates.busy') : t('templates.download')}
</button>
{snippet && (
<div className="space-y-1.5">
<p className="text-xs font-semibold text-foreground">{t('templates.snippetTitle')}</p>
<pre className="overflow-x-auto rounded border border-border bg-muted p-3 text-xs text-foreground">
{snippet}
</pre>
<p className="text-xs text-muted-foreground">{t('templates.snippetHint')}</p>
<button type="button" onClick={() => onCopy(snippet)} className="btn btn-secondary">
{copied ? t('templates.copied') : t('templates.copy')}
</button>
</div>
)}
</li>
);
}
@@ -10,9 +10,10 @@ import { EmptyWorkspace } from './components/EmptyWorkspace';
import { FilesTab } from './components/FilesTab';
import { MergeTab } from './components/MergeTab';
import { SplitTab } from './components/SplitTab';
import { TemplatesTab } from './components/TemplatesTab';
import { useCertWorkspace } from './use-cert-workspace';
type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert';
type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert' | 'templates';
/**
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
@@ -34,6 +35,7 @@ export default function CertManagerPage() {
{ id: 'split', label: t('tabs.split') },
{ id: 'merge', label: t('tabs.merge') },
{ id: 'convert', label: t('tabs.convert') },
{ id: 'templates', label: t('tabs.templates') },
];
return (
@@ -52,6 +54,7 @@ export default function CertManagerPage() {
{activeTab === 'split' && <SplitTab workspace={workspace} />}
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
{activeTab === 'convert' && <ConvertTab workspace={workspace} />}
{activeTab === 'templates' && <TemplatesTab workspace={workspace} />}
</div>
)}
</div>