feat(cert-manager): Zertifikatspaket hochladen, erkennen und in jedem Format herunterladen
Neuer erster Reiter Übersicht: mehrere Dateien oder die ZIP vom Aussteller auf einmal ablegen. Der Server erkennt jedes Teil (Server-, Zwischen-, Stammzertifikat, privater Schlüssel, CSR, auch aus PFX/P7B), fasst Duplikate zusammen, ordnet Schlüssel/CSR dem Zertifikat zu und baut die Kette. Unter jedem Teil stehen Download-Knöpfe für alle passenden Formate (crt, cer, Fullchain, p7b, pfx mit Schlüssel und Kette; key PKCS#8/PKCS#1/ DER; csr PEM/DER). Geschützte PFX lassen sich mit Passwort entsperren. Neue Endpunkte POST analyze (Dateien/ZIP, Begrenzung Anzahl und Größe vor dem Entpacken) und POST export (JSON, zustandslos). Modultexte siezen jetzt durchgehend; Gültigkeit in UTC wie im Zertifikat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -208,3 +208,81 @@ export async function postForm(
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||
export type BundleExportFormat =
|
||||
| 'crt'
|
||||
| 'cer'
|
||||
| 'fullchain'
|
||||
| 'p7b'
|
||||
| 'pfx'
|
||||
| 'key'
|
||||
| 'key-rsa'
|
||||
| 'key-der'
|
||||
| 'csr'
|
||||
| 'csr-der';
|
||||
|
||||
export interface BundleItem {
|
||||
id: string;
|
||||
kind: BundleItemKind;
|
||||
role?: CertRole;
|
||||
sources: string[];
|
||||
pem: string;
|
||||
baseName: string;
|
||||
cn: string;
|
||||
organization: string;
|
||||
issuerCn: string;
|
||||
notBefore: string | null;
|
||||
notAfter: string | null;
|
||||
isExpired: boolean | null;
|
||||
daysLeft: number | null;
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number;
|
||||
serialNumber: string;
|
||||
sha256: string;
|
||||
matchId: string | null;
|
||||
chainIds: string[];
|
||||
formats: BundleExportFormat[];
|
||||
}
|
||||
|
||||
export interface BundleAnalysis {
|
||||
items: BundleItem[];
|
||||
locked: string[];
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
|
||||
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append('files', file);
|
||||
if (password) form.append('password', password);
|
||||
return postForm('analyze', form) as Promise<BundleAnalysis>;
|
||||
}
|
||||
|
||||
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
|
||||
export async function exportBundleItemAction(input: {
|
||||
kind: BundleItemKind;
|
||||
pem: string;
|
||||
format: BundleExportFormat;
|
||||
baseName: string;
|
||||
chain?: string[];
|
||||
keyPem?: string;
|
||||
password?: string;
|
||||
}): Promise<FileResponse> {
|
||||
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`${response.status} ${body}`.trim());
|
||||
}
|
||||
return response.json() as Promise<FileResponse>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('next-intl', () => ({
|
||||
const certManager: Record<string, string> = {
|
||||
'title': 'Zertifikat-Manager',
|
||||
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
||||
'tabs.overview': 'Uebersicht',
|
||||
'tabs.inspect': 'Analysieren',
|
||||
'tabs.split': 'Aufteilen',
|
||||
'tabs.merge': 'Zusammenfuehren',
|
||||
@@ -76,8 +77,15 @@ describe('CertManagerPage shell', () => {
|
||||
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Inspect tab empty state on initial render', () => {
|
||||
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
|
||||
render(<CertManagerPage />);
|
||||
expect(screen.getByText('dropTitle')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Inspect tab empty state when Inspect tab is active', () => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.click(screen.getAllByText('Analysieren')[0]);
|
||||
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key} ${JSON.stringify(values)}` : key,
|
||||
}));
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
analyzeBundleAction: vi.fn(),
|
||||
exportBundleItemAction: vi.fn(),
|
||||
downloadBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
analyzeBundleAction,
|
||||
type BundleItem,
|
||||
downloadBase64,
|
||||
exportBundleItemAction,
|
||||
} from '../actions';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
|
||||
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
|
||||
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
|
||||
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
|
||||
|
||||
function item(over: Partial<BundleItem>): BundleItem {
|
||||
return {
|
||||
id: 'x',
|
||||
kind: 'certificate',
|
||||
sources: ['a.pem'],
|
||||
pem: 'PEM',
|
||||
baseName: 'www.example.test',
|
||||
cn: 'www.example.test',
|
||||
organization: '',
|
||||
issuerCn: '',
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
isExpired: null,
|
||||
daysLeft: null,
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
serialNumber: '',
|
||||
sha256: '',
|
||||
matchId: null,
|
||||
chainIds: [],
|
||||
formats: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const LEAF = item({
|
||||
id: 'leaf',
|
||||
role: 'end-entity',
|
||||
pem: 'LEAF-PEM',
|
||||
issuerCn: 'Test Intermediate CA',
|
||||
notBefore: '2026-01-01T00:00:00.000Z',
|
||||
notAfter: '2027-01-01T00:00:00.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 200,
|
||||
san: ['www.example.test', 'example.test'],
|
||||
matchId: 'key',
|
||||
chainIds: ['inter'],
|
||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||
});
|
||||
const INTER = item({
|
||||
id: 'inter',
|
||||
role: 'intermediate',
|
||||
pem: 'INTER-PEM',
|
||||
cn: 'Test Intermediate CA',
|
||||
isExpired: false,
|
||||
daysLeft: 900,
|
||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||
});
|
||||
const KEY = item({
|
||||
id: 'key',
|
||||
kind: 'privateKey',
|
||||
pem: 'KEY-PEM',
|
||||
matchId: 'leaf',
|
||||
formats: ['key', 'key-rsa', 'key-der'],
|
||||
});
|
||||
const CSR = item({
|
||||
id: 'csr',
|
||||
kind: 'csr',
|
||||
pem: 'CSR-PEM',
|
||||
matchId: 'leaf',
|
||||
formats: ['csr', 'csr-der'],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function upload(files: File[]) {
|
||||
render(<OverviewTab />);
|
||||
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
|
||||
}
|
||||
|
||||
describe('OverviewTab', () => {
|
||||
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [LEAF, INTER, KEY, CSR],
|
||||
locked: [],
|
||||
ignored: ['.dnstxtrecord'],
|
||||
});
|
||||
const zip = new File(['zip'], 'paket.zip');
|
||||
await upload([zip]);
|
||||
|
||||
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
|
||||
const cards = await screen.findAllByTestId('bundle-item');
|
||||
expect(cards).toHaveLength(4);
|
||||
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
|
||||
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
|
||||
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
|
||||
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
|
||||
// Zuordnung und Gueltigkeit
|
||||
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
|
||||
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
|
||||
// alle Formate als Knoepfe
|
||||
expect(
|
||||
within(cards[0])
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
|
||||
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
|
||||
expect(screen.getByText(/ignored/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'www.example.test-fullchain.pem',
|
||||
content: 'Zm9v',
|
||||
mimeType: 'x',
|
||||
});
|
||||
await upload([new File(['x'], 'a.pem')]);
|
||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||
|
||||
fireEvent.click(within(leafCard).getByText('format.fullchain'));
|
||||
await waitFor(() =>
|
||||
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
|
||||
);
|
||||
expect(mockExport).toHaveBeenCalledWith({
|
||||
kind: 'certificate',
|
||||
pem: 'LEAF-PEM',
|
||||
format: 'fullchain',
|
||||
baseName: 'www.example.test',
|
||||
chain: ['INTER-PEM'],
|
||||
keyPem: 'KEY-PEM',
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('PFX fragt erst ein Passwort ab', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'www.example.test.pfx',
|
||||
content: 'Zm9v',
|
||||
mimeType: 'x',
|
||||
});
|
||||
await upload([new File(['x'], 'a.pem')]);
|
||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||
|
||||
fireEvent.click(within(leafCard).getByText('format.pfx'));
|
||||
expect(mockExport).not.toHaveBeenCalled();
|
||||
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
|
||||
const download = within(leafCard).getByText('pfxDownload');
|
||||
expect(download).toBeDisabled();
|
||||
|
||||
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
|
||||
target: { value: 'geheim' },
|
||||
});
|
||||
fireEvent.click(download);
|
||||
await waitFor(() =>
|
||||
expect(mockExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('geschuetzte PFX: Passwort eingeben und erneut pruefen', async () => {
|
||||
mockAnalyze.mockResolvedValueOnce({ items: [LEAF], locked: ['a.pfx'], ignored: [] });
|
||||
const pfx = new File(['x'], 'a.pfx');
|
||||
await upload([pfx]);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
|
||||
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
|
||||
fireEvent.click(screen.getByText('unlock'));
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
|
||||
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Fehler beim Pruefen wird angezeigt', async () => {
|
||||
mockAnalyze.mockRejectedValue(new Error('400'));
|
||||
await upload([new File(['x'], 'a.txt')]);
|
||||
expect(await screen.findByText('error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
analyzeBundleAction,
|
||||
type BundleAnalysis,
|
||||
type BundleExportFormat,
|
||||
type BundleItem,
|
||||
downloadBase64,
|
||||
exportBundleItemAction,
|
||||
} from '../actions';
|
||||
|
||||
/**
|
||||
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
|
||||
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
|
||||
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
|
||||
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
|
||||
*
|
||||
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
|
||||
*/
|
||||
|
||||
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
|
||||
|
||||
const ROLE_STYLES: Record<string, string> = {
|
||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
csr: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
function typeKey(item: BundleItem): string {
|
||||
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
|
||||
}
|
||||
|
||||
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
export function OverviewTab() {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BundleAnalysis | null>(null);
|
||||
|
||||
async function analyze(next: File[], pw: string) {
|
||||
if (next.length === 0) {
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await analyzeBundleAction(next, pw || undefined));
|
||||
} catch {
|
||||
setResult(null);
|
||||
setError(t('error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addFiles(list: FileList | null) {
|
||||
const added = Array.from(list ?? []);
|
||||
if (added.length === 0) return;
|
||||
const next = [...files, ...added];
|
||||
setFiles(next);
|
||||
void analyze(next, password);
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
const next = files.filter((_, i) => i !== index);
|
||||
setFiles(next);
|
||||
void analyze(next, password);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setFiles([]);
|
||||
setPassword('');
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="hidden"
|
||||
data-testid="overview-file-input"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm text-foreground">{t('dropTitle')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
|
||||
</button>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{files.map((file, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
|
||||
key={`${file.name}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
{file.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('removeFile', { name: file.name })}
|
||||
onClick={() => removeFile(i)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
|
||||
{t('reset')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{result && result.locked.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-status-warn/50 bg-status-warn/10 p-4">
|
||||
<p className="text-sm text-foreground">
|
||||
{t('locked', { files: result.locked.join(', ') })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('lockedPassword')}
|
||||
aria-label={t('lockedPassword')}
|
||||
autoComplete="off"
|
||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!password || loading}
|
||||
onClick={() => void analyze(files, password)}
|
||||
>
|
||||
{t('unlock')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.items.length === 0 && !loading && (
|
||||
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
|
||||
)}
|
||||
|
||||
{result && result.items.length > 0 && (
|
||||
<ul className="space-y-4">
|
||||
{result.items.map((item) => (
|
||||
<BundleItemCard key={item.id} item={item} byId={byId} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{result && result.ignored.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ignored', { files: result.ignored.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const [pfxOpen, setPfxOpen] = useState(false);
|
||||
const [pfxPassword, setPfxPassword] = useState('');
|
||||
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const match = item.matchId ? byId[item.matchId] : undefined;
|
||||
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
|
||||
|
||||
async function download(format: BundleExportFormat, password?: string) {
|
||||
setBusy(format);
|
||||
setError(null);
|
||||
try {
|
||||
const file = await exportBundleItemAction({
|
||||
kind: item.kind,
|
||||
pem: item.pem,
|
||||
format,
|
||||
baseName: item.baseName,
|
||||
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
|
||||
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
|
||||
password,
|
||||
});
|
||||
downloadBase64(file.filename, file.content, file.mimeType);
|
||||
if (format === 'pfx') {
|
||||
setPfxOpen(false);
|
||||
setPfxPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError(t('exportError'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const status =
|
||||
item.isExpired === null
|
||||
? null
|
||||
: item.isExpired
|
||||
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
|
||||
: (item.daysLeft ?? 0) <= 30
|
||||
? {
|
||||
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
|
||||
}
|
||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
|
||||
{t(`type.${typeKey(item)}`)}
|
||||
</span>
|
||||
<span className="font-medium break-all">{item.cn || item.baseName}</span>
|
||||
{status && (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||
{status.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
{item.kind === 'certificate' && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('issuer')}</dt>
|
||||
<dd className="break-all">{item.issuerCn}</dd>
|
||||
<dt className="text-muted-foreground">{t('validity')}</dt>
|
||||
<dd>
|
||||
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{item.san.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('names')}</dt>
|
||||
<dd className="break-all">{item.san.join(', ')}</dd>
|
||||
</>
|
||||
)}
|
||||
{item.keyType && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('key')}</dt>
|
||||
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
|
||||
</>
|
||||
)}
|
||||
{match && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
|
||||
<dd className="break-all">
|
||||
{t(`type.${typeKey(match)}`)} {match.cn}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('source')}</dt>
|
||||
<dd className="break-all">{item.sources.join(', ')}</dd>
|
||||
</dl>
|
||||
|
||||
{item.kind === 'privateKey' && (
|
||||
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.formats.map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
|
||||
title={t(`formatHint.${format}`)}
|
||||
>
|
||||
{busy === format ? t('downloading') : t(`format.${format}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pfxOpen && (
|
||||
<div className="mt-3 space-y-2 rounded border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={pfxPassword}
|
||||
onChange={(e) => setPfxPassword(e.target.value)}
|
||||
placeholder={t('pfxPassword')}
|
||||
aria-label={t('pfxPassword')}
|
||||
autoComplete="new-password"
|
||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!pfxPassword || busy !== null}
|
||||
onClick={() => void download('pfx', pfxPassword)}
|
||||
>
|
||||
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import { InspectTab } from './components/InspectTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { ConvertTab } from './components/ConvertTab';
|
||||
import { OverviewTab } from './components/OverviewTab';
|
||||
|
||||
type TabId = 'inspect' | 'split' | 'merge' | 'convert';
|
||||
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
|
||||
|
||||
const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert'];
|
||||
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
|
||||
|
||||
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
||||
function isPfxFile(file: File | null): boolean {
|
||||
@@ -23,6 +24,7 @@ function isPfxFile(file: File | null): boolean {
|
||||
|
||||
/**
|
||||
* CertManagerPage — tab-based shell for certificate operations.
|
||||
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
|
||||
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
||||
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
||||
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
||||
@@ -34,7 +36,7 @@ function isPfxFile(file: File | null): boolean {
|
||||
export default function CertManagerPage() {
|
||||
const t = useTranslations('certManager');
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('inspect');
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [pemText, setPemText] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -75,17 +77,14 @@ export default function CertManagerPage() {
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case 'overview':
|
||||
return <OverviewTab />;
|
||||
case 'inspect':
|
||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
||||
case 'split':
|
||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
||||
case 'merge':
|
||||
return (
|
||||
<MergeTab
|
||||
password={password}
|
||||
onOutputFormatChange={setMergeOutputFormat}
|
||||
/>
|
||||
);
|
||||
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
|
||||
case 'convert':
|
||||
return (
|
||||
<ConvertTab
|
||||
@@ -102,42 +101,8 @@ export default function CertManagerPage() {
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||
|
||||
{/* Shared input card */}
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||
{/* DropZone */}
|
||||
<DropZone
|
||||
onFile={handleFile}
|
||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
||||
currentFile={file}
|
||||
onClear={handleClearFile}
|
||||
/>
|
||||
|
||||
{/* ODER divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<hr className="flex-1 border-border" />
|
||||
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
||||
<hr className="flex-1 border-border" />
|
||||
</div>
|
||||
|
||||
{/* PEM textarea */}
|
||||
<textarea
|
||||
value={pemText}
|
||||
onChange={(e) => handlePemChange(e.target.value)}
|
||||
placeholder={t('paste.placeholder')}
|
||||
rows={4}
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||
/>
|
||||
|
||||
{/* Conditional password field (T-09-02) */}
|
||||
<PasswordField
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
show={showPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="border-b border-border flex gap-6">
|
||||
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -154,6 +119,39 @@ export default function CertManagerPage() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
|
||||
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
|
||||
{activeTab !== 'overview' && (
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||
{/* DropZone */}
|
||||
<DropZone
|
||||
onFile={handleFile}
|
||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
||||
currentFile={file}
|
||||
onClear={handleClearFile}
|
||||
/>
|
||||
|
||||
{/* ODER divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<hr className="flex-1 border-border" />
|
||||
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
||||
<hr className="flex-1 border-border" />
|
||||
</div>
|
||||
|
||||
{/* PEM textarea */}
|
||||
<textarea
|
||||
value={pemText}
|
||||
onChange={(e) => handlePemChange(e.target.value)}
|
||||
placeholder={t('paste.placeholder')}
|
||||
rows={4}
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||
/>
|
||||
|
||||
{/* Conditional password field (T-09-02) */}
|
||||
<PasswordField value={password} onChange={setPassword} show={showPassword} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content card */}
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
||||
{renderActiveTab()}
|
||||
|
||||
@@ -1182,8 +1182,9 @@
|
||||
},
|
||||
"certManager": {
|
||||
"title": "Zertifikat-Manager",
|
||||
"description": "Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
||||
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
||||
"tabs": {
|
||||
"overview": "Übersicht",
|
||||
"inspect": "Analysieren",
|
||||
"split": "Aufteilen",
|
||||
"merge": "Zusammenführen",
|
||||
@@ -1217,18 +1218,85 @@
|
||||
},
|
||||
"emptyState": {
|
||||
"inspect": "Kein Zertifikat geladen.",
|
||||
"inspectBody": "Lade eine Datei hoch oder füge PEM-Text ein.",
|
||||
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
|
||||
"split": "Keine Datei geladen.",
|
||||
"splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.",
|
||||
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
|
||||
"merge": "Keine Zertifikate ausgewählt.",
|
||||
"mergeBody": "Lade mindestens zwei Dateien hoch.",
|
||||
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
|
||||
"convert": "Keine Datei geladen.",
|
||||
"convertBody": "Lade eine Datei hoch und wähle ein Ausgabeformat."
|
||||
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
|
||||
},
|
||||
"error": {
|
||||
"generic": "Verarbeitung fehlgeschlagen. Prüfe das Dateiformat oder das Passwort.",
|
||||
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
|
||||
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
||||
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
||||
},
|
||||
"overview": {
|
||||
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
|
||||
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||
"removeFile": "{name} entfernen",
|
||||
"reset": "Alle entfernen",
|
||||
"analyzing": "Dateien werden geprüft …",
|
||||
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
|
||||
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
|
||||
"lockedPassword": "Passwort der geschützten Datei",
|
||||
"unlock": "Entsperren",
|
||||
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
|
||||
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
|
||||
"type": {
|
||||
"end-entity": "Serverzertifikat",
|
||||
"intermediate": "Zwischenzertifikat",
|
||||
"root": "Stammzertifikat",
|
||||
"privateKey": "Privater Schlüssel",
|
||||
"csr": "Zertifikatsanfrage (CSR)"
|
||||
},
|
||||
"explain": {
|
||||
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
|
||||
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
|
||||
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
|
||||
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
|
||||
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
|
||||
},
|
||||
"issuer": "Ausgestellt von",
|
||||
"validity": "Gültig",
|
||||
"names": "Gilt für",
|
||||
"key": "Schlüssel",
|
||||
"belongsTo": "Gehört zu",
|
||||
"source": "Gefunden in",
|
||||
"valid": "Gültig",
|
||||
"expired": "Abgelaufen",
|
||||
"expiresSoon": "Läuft in {days} Tagen ab",
|
||||
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
|
||||
"downloading": "Wird erstellt …",
|
||||
"exportError": "Diese Datei konnte nicht erstellt werden.",
|
||||
"format": {
|
||||
"crt": "PEM (.crt)",
|
||||
"cer": "DER (.cer)",
|
||||
"fullchain": "Mit Kette (.pem)",
|
||||
"p7b": "PKCS#7 (.p7b)",
|
||||
"pfx": "PFX (.pfx)",
|
||||
"key": "PEM (.key)",
|
||||
"key-rsa": "RSA-PEM (.rsa.key)",
|
||||
"key-der": "DER (.key.der)",
|
||||
"csr": "PEM (.csr)",
|
||||
"csr-der": "DER (.csr.der)"
|
||||
},
|
||||
"formatHint": {
|
||||
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
|
||||
"cer": "Binärformat, z. B. für Windows und Java",
|
||||
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
|
||||
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
|
||||
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
|
||||
"key": "Schlüssel im Standardformat (PKCS#8)",
|
||||
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
|
||||
"key-der": "Schlüssel als Binärdatei",
|
||||
"csr": "Zertifikatsanfrage als Text",
|
||||
"csr-der": "Zertifikatsanfrage als Binärdatei"
|
||||
},
|
||||
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
|
||||
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
|
||||
"pfxPassword": "Passwort für die PFX-Datei",
|
||||
"pfxDownload": "PFX herunterladen"
|
||||
}
|
||||
},
|
||||
"tenderRadar": {
|
||||
|
||||
@@ -1182,8 +1182,9 @@
|
||||
},
|
||||
"certManager": {
|
||||
"title": "Certificate Manager",
|
||||
"description": "Inspect, split, merge and convert certificates.",
|
||||
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"inspect": "Inspect",
|
||||
"split": "Split",
|
||||
"merge": "Merge",
|
||||
@@ -1229,6 +1230,73 @@
|
||||
"generic": "Processing failed. Check the file format or password.",
|
||||
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
||||
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
||||
},
|
||||
"overview": {
|
||||
"dropTitle": "Drop certificate files or a ZIP here, or click",
|
||||
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||
"removeFile": "Remove {name}",
|
||||
"reset": "Remove all",
|
||||
"analyzing": "Checking files …",
|
||||
"error": "The files could not be checked. Please make sure they are certificate files.",
|
||||
"locked": "Protected: {files}. Enter the password to read this content as well.",
|
||||
"lockedPassword": "Password of the protected file",
|
||||
"unlock": "Unlock",
|
||||
"nothingFound": "No certificate, key or certificate request was found in the files.",
|
||||
"ignored": "Not used (no certificate detected): {files}",
|
||||
"type": {
|
||||
"end-entity": "Server certificate",
|
||||
"intermediate": "Intermediate certificate",
|
||||
"root": "Root certificate",
|
||||
"privateKey": "Private key",
|
||||
"csr": "Certificate request (CSR)"
|
||||
},
|
||||
"explain": {
|
||||
"end-entity": "The actual certificate for your domain – it goes on the web server.",
|
||||
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
|
||||
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
|
||||
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
|
||||
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
|
||||
},
|
||||
"issuer": "Issued by",
|
||||
"validity": "Valid",
|
||||
"names": "Valid for",
|
||||
"key": "Key",
|
||||
"belongsTo": "Belongs to",
|
||||
"source": "Found in",
|
||||
"valid": "Valid",
|
||||
"expired": "Expired",
|
||||
"expiresSoon": "Expires in {days} days",
|
||||
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
|
||||
"downloading": "Creating …",
|
||||
"exportError": "This file could not be created.",
|
||||
"format": {
|
||||
"crt": "PEM (.crt)",
|
||||
"cer": "DER (.cer)",
|
||||
"fullchain": "With chain (.pem)",
|
||||
"p7b": "PKCS#7 (.p7b)",
|
||||
"pfx": "PFX (.pfx)",
|
||||
"key": "PEM (.key)",
|
||||
"key-rsa": "RSA PEM (.rsa.key)",
|
||||
"key-der": "DER (.key.der)",
|
||||
"csr": "PEM (.csr)",
|
||||
"csr-der": "DER (.csr.der)"
|
||||
},
|
||||
"formatHint": {
|
||||
"crt": "Text format, e.g. for Apache, Nginx and most devices",
|
||||
"cer": "Binary format, e.g. for Windows and Java",
|
||||
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
|
||||
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
|
||||
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
|
||||
"key": "Key in standard format (PKCS#8)",
|
||||
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
|
||||
"key-der": "Key as binary file",
|
||||
"csr": "Certificate request as text",
|
||||
"csr-der": "Certificate request as binary file"
|
||||
},
|
||||
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
|
||||
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
|
||||
"pfxPassword": "Password for the PFX file",
|
||||
"pfxDownload": "Download PFX"
|
||||
}
|
||||
},
|
||||
"tenderRadar": {
|
||||
|
||||
@@ -103,6 +103,13 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
||||
* and must never be touched by the replacement or flagged by the guard.
|
||||
*/
|
||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
|
||||
'Aussteller',
|
||||
'Betriebssystemen',
|
||||
'Neuausstellung',
|
||||
'passwortgeschützten',
|
||||
'Schlüssel',
|
||||
'Zertifizierungsstelle',
|
||||
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
||||
'lässt',
|
||||
'manuell',
|
||||
|
||||
Reference in New Issue
Block a user