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>
);