--- phase: 09-cert-manager-module plan: 02 type: execute wave: 1 depends_on: [] files_modified: - apps/web/src/messages/de.json - apps/web/src/messages/en.json - 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 autonomous: true requirements: [CERT-06] must_haves: truths: - "The /modules/cert-manager page renders the title, description and four tabs (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren)" - "The shared input card shows a drag-and-drop DropZone, an OR divider, a PEM textarea, and a conditionally-shown password field" - "Selecting a .pfx/.p12 file OR choosing PFX output reveals the password field; otherwise it is hidden" - "The certManager i18n namespace resolves in both de.json and en.json with no missing keys" artifacts: - "apps/web/src/app/(portal)/modules/cert-manager/page.tsx (tab shell + shared input state)" - "apps/web/src/app/(portal)/modules/cert-manager/actions.ts (API_URL, downloadBase64, postForm helpers)" - "apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx + PasswordField.tsx + 4 tab stubs" - "certManager namespace in de.json and en.json" - "apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx (shell tests)" key_links: - "page.tsx passes { file, pemText, password } down to the active tab component" - "useTranslations('certManager') resolves keys defined in messages/de.json + en.json" --- Build the frontend shell for the cert-manager module: the tab-based page, shared input card (DropZone + PEM textarea + conditional password field), reusable download/fetch helpers, empty tab-component stubs, and the full `certManager` i18n namespace in German and English. MVP framing — this delivers the visible half of the first vertical slice: after this plan a user who activates the module can open `/modules/cert-manager`, see all four tabs and the input card, and read localized copy, even though no operation is wired yet. Purpose: Every feature slice (Inspect, Split, Convert, Merge) fills in one tab component and one action against this shell. Output: Rendering cert-manager page + localized strings + shared client helpers. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-cert-manager-module/09-CONTEXT.md @.planning/phases/09-cert-manager-module/09-UI-SPEC.md @.planning/phases/09-cert-manager-module/09-PATTERNS.md @apps/web/src/app/(portal)/modules/domaincheck/page.tsx @apps/web/src/app/(portal)/modules/domaincheck/actions.ts @apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx @apps/web/src/messages/de.json ## Artifacts this plan produces - New route page: `CertManagerPage` (default export, 'use client') at `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` - New symbols in `actions.ts`: `API_URL` const, `downloadBase64(filename, content, mimeType)`, `postForm(endpoint, form)` (fetch wrapper, credentials:'include', throws on !ok) - New components: `DropZone` (props: onFile, accept), `PasswordField` (props: value, onChange, show), `InspectTab`, `SplitTab`, `MergeTab`, `ConvertTab` (each props: file, pemText, password — render empty state for now) - New i18n namespace `certManager` added to `apps/web/src/messages/de.json` and `en.json` - New test file: `apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx` Task 1: Add certManager i18n namespace (de + en) apps/web/src/messages/de.json, apps/web/src/messages/en.json - apps/web/src/messages/de.json (locate the existing domaincheck namespace; append certManager as a sibling — do not restructure) - apps/web/src/messages/en.json (same) - .planning/phases/09-cert-manager-module/09-RESEARCH.md (i18n Namespace Structure de.json — canonical key set) - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Copywriting Contract — exact German strings) Add a certManager namespace to de.json using the exact keys and German strings from the RESEARCH i18n Namespace Structure and the UI-SPEC Copywriting Contract: title 'Zertifikat-Manager', description 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', tabs.{inspect,split,merge,convert} = Analysieren/Aufteilen/Zusammenfuehren/Konvertieren, dropZone.{placeholder,formats}, paste.placeholder, password.label 'Passwort (PFX/P12)', or 'oder', actions.{inspect,split,merge,convert,download,processing}, emptyState.{inspect,inspectBody,split,splitBody,merge,mergeBody,convert,convertBody}, error.{generic,wrongPassword,unknownFormat}. Use the exact umlaut spellings from UI-SPEC. Add the same key structure to en.json with English equivalents. Preserve existing JSON ordering/formatting; append the namespace only. node -e "const de=require('./apps/web/src/messages/de.json'); const en=require('./apps/web/src/messages/en.json'); const k=Object.keys(de.certManager.tabs).sort().join(','); if(k!=='convert,inspect,merge,split') throw new Error('de tabs '+k); if(!en.certManager.actions.download) throw new Error('en missing download'); console.log('I18N_OK');" - `de.certManager.title` equals 'Zertifikat-Manager' and `de.certManager.tabs.merge` equals 'Zusammenfuehren' - de.json and en.json share identical key paths under certManager (same tabs, actions, emptyState, error keys) - `pnpm --filter @tessera/web type-check` still passes (valid JSON) certManager namespace exists in both de.json and en.json with the full key set; German copy matches the UI-SPEC Copywriting Contract. Task 2: Build page shell, shared input card, DropZone, PasswordField, tab stubs, and client helpers 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/domaincheck/page.tsx (client component + useTranslations + Card layout + loading/error state pattern) - apps/web/src/app/(portal)/modules/domaincheck/actions.ts (fetch wrapper + credentials:'include' pattern) - apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/CsvImportButton.tsx (hidden file input + drag-over DropZone pattern) - .planning/phases/09-cert-manager-module/09-UI-SPEC.md (Layout Contract, Conditional Elements, Interaction Contract, Color/Spacing/Typography) - .planning/phases/09-cert-manager-module/09-PATTERNS.md (page.tsx header/layout pattern, DropZone pattern, actions.ts pattern, downloadBase64 helper) Create page.tsx as a 'use client' component using useTranslations('certManager'). State: activeTab ('inspect'|'split'|'merge'|'convert'), file (File|null), pemText (string), password (string). Layout per UI-SPEC: max-w-4xl mx-auto p-6 space-y-6; header (h1 text-2xl font-bold tracking-tight + p text-sm text-muted-foreground); shared input Card (rounded-lg border border-border bg-card p-6 shadow-sm space-y-4) containing DropZone, an 'oder' divider (t('or')), a PEM textarea (t('paste.placeholder')), and PasswordField shown only when the selected file extension is .pfx/.p12 OR activeTab is 'merge' with PFX output (pass a `show` prop). Tab nav (border-b border-border flex gap-6; active tab border-b-2 border-primary text-foreground, inactive text-muted-foreground). Tab content Card renders the active tab component, passing { file, pemText, password }. Selecting a file clears pemText and vice versa (single active source per Interaction Contract). Changing the active tab clears the previous tab's result but keeps the shared input. Create components/DropZone.tsx per PATTERNS DropZone pattern: hidden file input, click-to-browse, drag-over highlight (border-primary bg-primary/5), accept prop, calls onFile; reset e.target.value to allow re-selecting the same file. Use i18n for placeholder text. Create components/PasswordField.tsx: input[type=password] with a show/hide toggle rendered as an inline SVG eye icon (no external icon lib per UI-SPEC); props value, onChange, show (render null when show is false — no reflow). Label from t('password.label'). Create components/InspectTab.tsx, SplitTab.tsx, MergeTab.tsx, ConvertTab.tsx as stubs: each accepts { file, pemText, password } and renders the corresponding empty state from t('emptyState.*'). No API calls yet — later slices fill these in. Create actions.ts with: API_URL const (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'); downloadBase64(filename, content, mimeType) per PATTERNS (atob -> Uint8Array -> Blob -> object URL -> anchor click -> revoke); a postForm(endpoint, form) helper that fetches `${API_URL}/modules/cert-manager/${endpoint}` with method POST, body form, credentials 'include', no manual Content-Type, and throws Error(`${status} ${body}`) on !response.ok, else returns response.json(). All strings via t(); no hardcoded UI copy. No shadcn, no Radix, Tailwind utilities only. pnpm --filter @tessera/web test cert-manager --run - `apps/web/src/app/(portal)/modules/cert-manager/page.tsx` starts with `'use client'` and calls `useTranslations('certManager')` - `grep -q "max-w-4xl" apps/web/src/app/(portal)/modules/cert-manager/page.tsx` - `grep -q "credentials: 'include'" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` - `grep -q "URL.createObjectURL" apps/web/src/app/(portal)/modules/cert-manager/actions.ts` - PasswordField renders nothing when `show` is false and renders an input[type=password] with a toggle when true - `pnpm --filter @tessera/web type-check` exits 0 The page renders the header, four tabs, shared input card with DropZone + textarea + conditional PasswordField, and delegates to tab stubs; actions.ts exposes downloadBase64 + postForm helpers. Task 3: Shell render tests apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx - apps/web/vitest.config.ts (jsdom env, globals, setupFiles ./src/test/setup.ts) - apps/web/src/test/setup.ts (existing test setup — how providers/i18n are wired for tests) - Any existing *.test.tsx under apps/web/src/app/(portal)/modules (reference NextIntlClientProvider wiring in component tests) - Test: page renders the title 'Zertifikat-Manager' and all four tab labels (Analysieren, Aufteilen, Zusammenfuehren, Konvertieren). - Test: the password field is NOT in the document on initial render (no PFX file, inspect tab). - Test: each tab, when active, shows its empty-state text from certManager.emptyState. Create cert-manager.test.tsx rendering CertManagerPage wrapped in NextIntlClientProvider with the de messages (follow the existing module component-test wiring found in read_first). Implement the Behavior assertions using @testing-library/react queries (getByText / queryByLabelText). Write the tests to describe the shell contract; they should pass against the Task 2 implementation (GREEN). If the shell is missing anything they assert, fix the shell. pnpm --filter @tessera/web test cert-manager --run - `pnpm --filter @tessera/web test cert-manager --run` exits 0 with the title, tab-label, hidden-password, and empty-state assertions passing - The test file imports NextIntlClientProvider and renders with de messages Shell render tests are green: title, four tabs, hidden password field, and per-tab empty states are asserted. ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser -> API | Client sends uploaded cert bytes + optional password to the API via fetch | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-09-02 | Information Disclosure | PasswordField / actions.ts | high | mitigate | Password is held in local React state and sent only in the FormData body over the authenticated fetch; never placed in URL query, console.log, or download filename | | T-09-04 | Elevation of Privilege | client fetch to /modules/cert-manager/* | high | mitigate | All requests use `credentials: 'include'`; the API enforces JwtAuthGuard + ModuleGuard, so an unauthenticated/unactivated client cannot process certs | | T-09-05 | Tampering | client-side accept filter | low | accept | `accept=".pem,.crt,..."` is a UX guard only; real validation happens server-side in the API (Plan 01/03+) — client filter is not a security boundary | - `pnpm --filter @tessera/web test cert-manager --run` — shell tests green - `pnpm --filter @tessera/web type-check` — web compiles - Manual (deferred to phase gate): open /modules/cert-manager after activation, confirm tabs, DropZone drag highlight, and password field toggling on .pfx selection - certManager i18n namespace complete in de + en - Page shell renders title, four tabs, shared input card, conditional password field - actions.ts exposes downloadBase64 + postForm; no shadcn/Radix used - Shell tests green; web type-checks clean Create `.planning/phases/09-cert-manager-module/09-02-SUMMARY.md` when done