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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user