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