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