From 82a80e76348e5d38fe5d3a726d68c7471bec0568 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 23:17:58 +0200 Subject: [PATCH 1/4] feat(09-02): add certManager i18n namespace (de + en) - Added certManager namespace to de.json with full key set (tabs, dropZone, paste, password, or, actions, emptyState, error) - Added certManager namespace to en.json with matching key structure - German copy matches UI-SPEC Copywriting Contract exactly - Both files share identical key paths under certManager --- apps/web/src/messages/de.json | 44 +++++++++++++++++++++++++++++++++++ apps/web/src/messages/en.json | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 034cefe..9993757 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -391,5 +391,49 @@ "detailVersion": "Version {version}", "detailStatusActive": "Aktiviert fuer diesen Mandanten", "detailStatusInactive": "Nicht aktiviert" + }, + "certManager": { + "title": "Zertifikat-Manager", + "description": "Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.", + "tabs": { + "inspect": "Analysieren", + "split": "Aufteilen", + "merge": "Zusammenfuehren", + "convert": "Konvertieren" + }, + "dropZone": { + "placeholder": "Datei hierher ziehen oder klicken", + "formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c" + }, + "paste": { + "placeholder": "PEM-Inhalt einfuegen (-----BEGIN ...)" + }, + "password": { + "label": "Passwort (PFX/P12)" + }, + "or": "oder", + "actions": { + "inspect": "Analysieren", + "split": "Aufteilen", + "merge": "Zusammenfuehren", + "convert": "Konvertieren", + "download": "Herunterladen", + "processing": "Wird verarbeitet..." + }, + "emptyState": { + "inspect": "Kein Zertifikat geladen.", + "inspectBody": "Lade eine Datei hoch oder fuege PEM-Text ein.", + "split": "Keine Datei geladen.", + "splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.", + "merge": "Keine Zertifikate ausgewaehlt.", + "mergeBody": "Lade mindestens zwei Dateien hoch.", + "convert": "Keine Datei geladen.", + "convertBody": "Lade eine Datei hoch und waehle ein Ausgabeformat." + }, + "error": { + "generic": "Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.", + "wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.", + "unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden." + } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index f653fa6..1d50e88 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -391,5 +391,49 @@ "detailVersion": "Version {version}", "detailStatusActive": "Activated for this tenant", "detailStatusInactive": "Not activated" + }, + "certManager": { + "title": "Certificate Manager", + "description": "Inspect, split, merge and convert certificates.", + "tabs": { + "inspect": "Inspect", + "split": "Split", + "merge": "Merge", + "convert": "Convert" + }, + "dropZone": { + "placeholder": "Drag file here or click to browse", + "formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c" + }, + "paste": { + "placeholder": "Paste PEM content (-----BEGIN ...)" + }, + "password": { + "label": "Password (PFX/P12)" + }, + "or": "or", + "actions": { + "inspect": "Inspect", + "split": "Split", + "merge": "Merge", + "convert": "Convert", + "download": "Download", + "processing": "Processing..." + }, + "emptyState": { + "inspect": "No certificate loaded.", + "inspectBody": "Upload a file or paste PEM text.", + "split": "No file loaded.", + "splitBody": "Upload a fullchain or P7B file.", + "merge": "No certificates selected.", + "mergeBody": "Upload at least two files.", + "convert": "No file loaded.", + "convertBody": "Upload a file and select an output format." + }, + "error": { + "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." + } } } From 42a41f77d0cc51f1f19eba574315c43558790468 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 23:20:10 +0200 Subject: [PATCH 2/4] feat(09-02): build cert-manager page shell, components, and client helpers - CertManagerPage: 'use client', useTranslations('certManager'), max-w-4xl layout - Shared input card with DropZone, OR divider, PEM textarea, conditional PasswordField - PasswordField renders null when show=false (T-09-02 threat mitigation) - Tab nav: Analysieren / Aufteilen / Zusammenfuehren / Konvertieren - Tab stubs: InspectTab, SplitTab, MergeTab, ConvertTab (empty state only) - actions.ts: API_URL const, downloadBase64(atob->Blob->URL), postForm(credentials:'include') - File/paste mutual exclusion: selecting one clears the other - No shadcn/Radix; Tailwind utilities only; inline SVG eye icon --- .../(portal)/modules/cert-manager/actions.ts | 51 +++++++ .../cert-manager/components/ConvertTab.tsx | 22 +++ .../cert-manager/components/DropZone.tsx | 99 ++++++++++++ .../cert-manager/components/InspectTab.tsx | 22 +++ .../cert-manager/components/MergeTab.tsx | 22 +++ .../cert-manager/components/PasswordField.tsx | 76 ++++++++++ .../cert-manager/components/SplitTab.tsx | 22 +++ .../(portal)/modules/cert-manager/page.tsx | 143 ++++++++++++++++++ 8 files changed, 457 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/actions.ts create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/page.tsx diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts new file mode 100644 index 0000000..b9fe80e --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -0,0 +1,51 @@ +export const API_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +/** + * Download a base64-encoded file as a browser download. + * T-09-02: password is never placed in URL, console.log, or filename. + */ +export function downloadBase64( + filename: string, + content: string, + mimeType: string, +): void { + const bytes = atob(content); + const byteArray = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + byteArray[i] = bytes.charCodeAt(i); + } + const blob = new Blob([byteArray], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +/** + * POST a FormData payload to a cert-manager endpoint. + * T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard. + * No manual Content-Type header — browser sets multipart boundary automatically. + */ +export async function postForm( + endpoint: string, + form: FormData, +): Promise { + const response = await fetch( + `${API_URL}/modules/cert-manager/${endpoint}`, + { + method: 'POST', + body: form, + credentials: 'include', + }, + ); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`${response.status} ${body}`.trim()); + } + + return response.json(); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx new file mode 100644 index 0000000..132d17f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +interface ConvertTabProps { + file: File | null; + pemText: string; + password: string; +} + +export function ConvertTab({ file: _file, pemText: _pemText, password: _password }: ConvertTabProps) { + const t = useTranslations('certManager'); + + return ( +
+
+

{t('emptyState.convert')}

+

{t('emptyState.convertBody')}

+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx new file mode 100644 index 0000000..18b6eb1 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +interface DropZoneProps { + onFile: (file: File) => void; + accept: string; + currentFile: File | null; + onClear: () => void; +} + +export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps) { + const t = useTranslations('certManager'); + const fileInputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFile(file); + } + // Allow re-selecting the same file + e.target.value = ''; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => { + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) { + onFile(file); + } + }; + + return ( +
+ +
{ if (e.key === 'Enter' || e.key === ' ') handleClick(); }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={`rounded-lg border-2 border-dashed p-8 text-center cursor-pointer transition-colors ${ + isDragOver + ? 'border-primary bg-primary/5' + : 'border-border hover:border-primary/50' + }`} + > + {currentFile ? ( +
+ + {currentFile.name}{' '} + + ({(currentFile.size / 1024).toFixed(1)} KB) + + + +
+ ) : ( +
+

+ {t('dropZone.placeholder')} +

+

{t('dropZone.formats')}

+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx new file mode 100644 index 0000000..5063a4f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +interface InspectTabProps { + file: File | null; + pemText: string; + password: string; +} + +export function InspectTab({ file: _file, pemText: _pemText, password: _password }: InspectTabProps) { + const t = useTranslations('certManager'); + + return ( +
+
+

{t('emptyState.inspect')}

+

{t('emptyState.inspectBody')}

+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx new file mode 100644 index 0000000..024cd2f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +interface MergeTabProps { + file: File | null; + pemText: string; + password: string; +} + +export function MergeTab({ file: _file, pemText: _pemText, password: _password }: MergeTabProps) { + const t = useTranslations('certManager'); + + return ( +
+
+

{t('emptyState.merge')}

+

{t('emptyState.mergeBody')}

+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx new file mode 100644 index 0000000..f31b07d --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; + +interface PasswordFieldProps { + value: string; + onChange: (value: string) => void; + /** When false, the component renders nothing (T-09-02: no DOM reflow) */ + show: boolean; +} + +export function PasswordField({ value, onChange, show }: PasswordFieldProps) { + const t = useTranslations('certManager'); + const [revealed, setRevealed] = useState(false); + + if (!show) return null; + + return ( +
+ +
+ onChange(e.target.value)} + className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground pr-10 focus:outline-none focus:ring-2 focus:ring-ring" + autoComplete="off" + /> + +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx new file mode 100644 index 0000000..27b6fce --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +interface SplitTabProps { + file: File | null; + pemText: string; + password: string; +} + +export function SplitTab({ file: _file, pemText: _pemText, password: _password }: SplitTabProps) { + const t = useTranslations('certManager'); + + return ( +
+
+

{t('emptyState.split')}

+

{t('emptyState.splitBody')}

+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx new file mode 100644 index 0000000..a1219a7 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { DropZone } from './components/DropZone'; +import { PasswordField } from './components/PasswordField'; +import { InspectTab } from './components/InspectTab'; +import { SplitTab } from './components/SplitTab'; +import { MergeTab } from './components/MergeTab'; +import { ConvertTab } from './components/ConvertTab'; + +type TabId = 'inspect' | 'split' | 'merge' | 'convert'; + +const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert']; + +/** Returns true if the selected file is a PFX/P12 (requires decryption password) */ +function isPfxFile(file: File | null): boolean { + if (!file) return false; + const name = file.name.toLowerCase(); + return name.endsWith('.pfx') || name.endsWith('.p12'); +} + +/** + * CertManagerPage — tab-based shell for certificate operations. + * 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'. + */ +export default function CertManagerPage() { + const t = useTranslations('certManager'); + + const [activeTab, setActiveTab] = useState('inspect'); + const [file, setFile] = useState(null); + const [pemText, setPemText] = useState(''); + const [password, setPassword] = useState(''); + + const showPassword = isPfxFile(file); + + const handleFile = (selected: File) => { + setFile(selected); + setPemText(''); // Single active source: file XOR paste + setPassword(''); + }; + + const handleClearFile = () => { + setFile(null); + setPassword(''); + }; + + const handlePemChange = (text: string) => { + setPemText(text); + if (text) { + setFile(null); // Single active source: file XOR paste + setPassword(''); + } + }; + + const handleTabChange = (tab: TabId) => { + setActiveTab(tab); + // Tab switch: keep shared input, clear result (result cleared per tab re-render) + }; + + const renderActiveTab = () => { + const props = { file, pemText, password }; + switch (activeTab) { + case 'inspect': + return ; + case 'split': + return ; + case 'merge': + return ; + case 'convert': + return ; + } + }; + + return ( +
+ {/* Header */} +
+

{t('title')}

+

{t('description')}

+
+ + {/* Shared input card */} +
+ {/* DropZone */} + + + {/* ODER divider */} +
+
+ {t('or')} +
+
+ + {/* PEM textarea */} +