diff --git a/.planning/phases/09-cert-manager-module/09-02-SUMMARY.md b/.planning/phases/09-cert-manager-module/09-02-SUMMARY.md new file mode 100644 index 0000000..67b02bc --- /dev/null +++ b/.planning/phases/09-cert-manager-module/09-02-SUMMARY.md @@ -0,0 +1,149 @@ +--- +phase: 09-cert-manager-module +plan: "02" +subsystem: frontend +tags: [cert-manager, frontend, i18n, components, shell] +requires: [] +provides: [cert-manager-page-shell, cert-manager-i18n, cert-manager-actions] +affects: [apps/web] +tech_stack: + added: [] + patterns: [useTranslations, vi.mock next-intl, client-component, conditional-render] +key_files: + created: + - apps/web/src/app/(portal)/modules/cert-manager/page.tsx + - apps/web/src/app/(portal)/modules/cert-manager/actions.ts + - apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx + - apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx + modified: + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json +decisions: + - "Used vi.mock('next-intl') test pattern per project convention instead of NextIntlClientProvider (matches VehicleTable.test.tsx, sidebar.test.tsx)" + - "PasswordField renders null when show=false — no DOM reflow, satisfies T-09-02 threat mitigation" + - "File/PEM mutual exclusion enforced in page state: selecting one clears the other" +metrics: + duration: "~6 minutes" + completed: "2026-07-01" + tasks_completed: 3 + files_created: 9 + files_modified: 2 +status: complete +--- + +# Phase 09 Plan 02: Cert-Manager Frontend Shell Summary + +**One-liner:** Cert-manager tab shell with DropZone, conditional PasswordField, shared input card, four tab stubs, downloadBase64/postForm client helpers, and full certManager i18n namespace in de + en. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Add certManager i18n namespace (de + en) | 82a80e7 | de.json, en.json | +| 2 | Build page shell, components, client helpers | 42a41f7 | page.tsx, actions.ts, 6 components | +| 3 | Shell render tests (GREEN) | 2cb01f7 | cert-manager.test.tsx | + +## What Was Built + +### i18n Namespace (Task 1) +`certManager` namespace added to both `de.json` and `en.json` with identical key structure: +- `title`, `description` +- `tabs.{inspect,split,merge,convert}` +- `dropZone.{placeholder,formats}`, `paste.placeholder`, `password.label`, `or` +- `actions.{inspect,split,merge,convert,download,processing}` +- `emptyState.{inspect,inspectBody,split,splitBody,merge,mergeBody,convert,convertBody}` +- `error.{generic,wrongPassword,unknownFormat}` + +German copy matches UI-SPEC Copywriting Contract exactly. + +### Page Shell (Task 2) + +**page.tsx** — `'use client'` component with: +- State: `activeTab`, `file`, `pemText`, `password` +- Layout: `max-w-4xl mx-auto p-6 space-y-6` per UI-SPEC +- Shared input card with DropZone + OR divider + PEM textarea + conditional PasswordField +- Tab nav: 4 tabs with `border-b-2 border-primary` active indicator +- Renders active tab component with `{ file, pemText, password }` props +- File/PEM mutual exclusion enforced + +**actions.ts** — Client helpers: +- `API_URL` const (`NEXT_PUBLIC_API_URL || 'http://localhost:3001'`) +- `downloadBase64(filename, content, mimeType)` — atob → Uint8Array → Blob → URL.createObjectURL +- `postForm(endpoint, form)` — fetch with `credentials: 'include'`, throws on !ok + +**DropZone.tsx** — Drag-and-drop + click-to-browse, drag-over highlight (`border-primary bg-primary/5`), file display with clear button, resets `e.target.value` for re-selection. + +**PasswordField.tsx** — `input[type=password]` with inline SVG eye toggle; renders `null` when `show=false` (T-09-02). + +**Tab stubs** — InspectTab, SplitTab, MergeTab, ConvertTab render empty state strings only. + +### Tests (Task 3) +7 tests, all GREEN: +- Page renders title 'Zertifikat-Manager' and description +- All 4 tab labels rendered (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren) +- Password field NOT in DOM on initial render (T-09-02 assertion) +- Inspect empty state on initial render +- Split / Merge / Convert empty states when respective tabs activated + +## Deviations from Plan + +### Auto-applied conventions + +**1. [Convention] vi.mock('next-intl') instead of NextIntlClientProvider** +- **Found during:** Task 3 +- **Issue:** Plan specified NextIntlClientProvider, but every existing test in the project mocks next-intl directly (sidebar.test.tsx, VehicleTable.test.tsx) +- **Fix:** Used `vi.mock('next-intl', ...)` pattern for consistency +- **Impact:** Tests are simpler and match project convention; no behavior difference + +**2. [Convention] node_modules symlink for worktree test execution** +- **Found during:** Task 3 +- **Issue:** Worktree has no node_modules; vitest not available +- **Fix:** Created temporary symlink `apps/web/node_modules -> main repo node_modules` to run tests; symlink is gitignored +- **Impact:** Tests pass correctly; symlink does not affect production + +## Threat Model Compliance + +| Threat ID | Status | +|-----------|--------| +| T-09-02 (password in state/URL) | Mitigated — PasswordField renders null when show=false; password never in URL or console | +| T-09-04 (unauthenticated fetch) | Mitigated — postForm uses credentials:'include'; ModuleGuard enforced server-side | +| T-09-05 (client accept filter) | Accepted — UX only, server validates | + +## Known Stubs + +| Component | Location | Reason | +|-----------|----------|--------| +| InspectTab | cert-manager/components/InspectTab.tsx | Empty state only — API wire-up in later plan | +| SplitTab | cert-manager/components/SplitTab.tsx | Empty state only — API wire-up in later plan | +| MergeTab | cert-manager/components/MergeTab.tsx | Empty state only — API wire-up in later plan | +| ConvertTab | cert-manager/components/ConvertTab.tsx | Empty state only — API wire-up in later plan | + +These stubs are intentional. Plan 02 explicitly scopes to the shell; later plans fill in each tab's API logic. The stubs do not prevent the plan goal (visible shell + navigation) from being achieved. + +## Verification Results + +| Check | Status | Notes | +|-------|--------|-------| +| `pnpm --filter @tessera/web test cert-manager --run` | PASS | 7/7 tests green | +| `tsc --noEmit` (cert-manager files) | PASS | No cert-manager type errors | +| I18N_OK node verify script | PASS | de.tabs keys correct, en.certManager.actions.download exists | +| `grep -q "max-w-4xl"` | PASS | Layout contract met | +| `grep -q "credentials: 'include'"` | PASS | T-09-04 mitigation present | +| `grep -q "URL.createObjectURL"` | PASS | downloadBase64 helper present | + +## Self-Check: PASSED + +- [x] apps/web/src/app/(portal)/modules/cert-manager/page.tsx — created +- [x] apps/web/src/app/(portal)/modules/cert-manager/actions.ts — created +- [x] apps/web/src/app/(portal)/modules/cert-manager/components/*.tsx (6 files) — created +- [x] apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx — created +- [x] apps/web/src/messages/de.json — certManager namespace added +- [x] apps/web/src/messages/en.json — certManager namespace added +- [x] Commit 82a80e7 exists (i18n) +- [x] Commit 42a41f7 exists (shell + components) +- [x] Commit 2cb01f7 exists (tests) 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/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx new file mode 100644 index 0000000..501a37f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -0,0 +1,101 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock next-intl — provide certManager namespace keys +vi.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string) => { + const certManager: Record = { + 'title': 'Zertifikat-Manager', + 'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', + 'tabs.inspect': 'Analysieren', + 'tabs.split': 'Aufteilen', + 'tabs.merge': 'Zusammenfuehren', + 'tabs.convert': 'Konvertieren', + 'dropZone.placeholder': 'Datei hierher ziehen oder klicken', + 'dropZone.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', + 'actions.split': 'Aufteilen', + 'actions.merge': 'Zusammenfuehren', + 'actions.convert': 'Konvertieren', + 'actions.download': 'Herunterladen', + 'actions.processing': 'Wird verarbeitet...', + 'emptyState.inspect': 'Kein Zertifikat geladen.', + 'emptyState.inspectBody': 'Lade eine Datei hoch oder fuege PEM-Text ein.', + 'emptyState.split': 'Keine Datei geladen.', + 'emptyState.splitBody': 'Lade eine Fullchain- oder P7B-Datei hoch.', + 'emptyState.merge': 'Keine Zertifikate ausgewaehlt.', + 'emptyState.mergeBody': 'Lade mindestens zwei Dateien hoch.', + 'emptyState.convert': 'Keine Datei geladen.', + 'emptyState.convertBody': 'Lade eine Datei hoch und waehle ein Ausgabeformat.', + 'error.generic': 'Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.', + 'error.wrongPassword': 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.', + 'error.unknownFormat': 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.', + }; + if (ns === 'certManager') { + return certManager[key] ?? key; + } + return key; + }, +})); + +import CertManagerPage from './page'; + +afterEach(() => { + cleanup(); +}); + +describe('CertManagerPage shell', () => { + it('renders the page title and description', () => { + render(); + expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument(); + expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument(); + }); + + it('renders all four tab labels', () => { + render(); + expect(screen.getByText('Analysieren')).toBeInTheDocument(); + expect(screen.getByText('Aufteilen')).toBeInTheDocument(); + expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument(); + expect(screen.getByText('Konvertieren')).toBeInTheDocument(); + }); + + it('does NOT render the password field on initial render (no PFX file selected)', () => { + render(); + // Password field label should not be in document when no PFX is selected + expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument(); + }); + + it('shows Inspect tab empty state on initial render', () => { + render(); + expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument(); + expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument(); + }); + + it('shows Split tab empty state when Split tab is active', () => { + render(); + // Click the "Aufteilen" tab button (second one in nav) + const tabButtons = screen.getAllByText('Aufteilen'); + // The tab nav button is the one that triggers tab change + fireEvent.click(tabButtons[0]); + expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument(); + expect(screen.getByText('Lade eine Fullchain- oder P7B-Datei hoch.')).toBeInTheDocument(); + }); + + it('shows Merge tab empty state when Merge tab is active', () => { + render(); + fireEvent.click(screen.getByText('Zusammenfuehren')); + expect(screen.getByText('Keine Zertifikate ausgewaehlt.')).toBeInTheDocument(); + expect(screen.getByText('Lade mindestens zwei Dateien hoch.')).toBeInTheDocument(); + }); + + it('shows Convert tab empty state when Convert tab is active', () => { + render(); + // "Konvertieren" appears once as tab label + fireEvent.click(screen.getByText('Konvertieren')); + expect(screen.getAllByText('Keine Datei geladen.').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument(); + }); +}); 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 */} +