merge(09-02): cert-manager web shell + i18n (resolve de/en.json conflict)
This commit is contained in:
@@ -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)
|
||||||
@@ -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<unknown> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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(<CertManagerPage />);
|
||||||
|
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all four tab labels', () => {
|
||||||
|
render(<CertManagerPage />);
|
||||||
|
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(<CertManagerPage />);
|
||||||
|
// 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(<CertManagerPage />);
|
||||||
|
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(<CertManagerPage />);
|
||||||
|
// 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(<CertManagerPage />);
|
||||||
|
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(<CertManagerPage />);
|
||||||
|
// "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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center py-8 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(null);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onFile(file);
|
||||||
|
}
|
||||||
|
// Allow re-selecting the same file
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(e) => { 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 ? (
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{currentFile.name}{' '}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({(currentFile.size / 1024).toFixed(1)} KB)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
||||||
|
className="text-muted-foreground hover:text-foreground text-xs border border-border rounded px-2 py-0.5"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('dropZone.placeholder')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center py-8 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center py-8 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-sm text-foreground">
|
||||||
|
{t('password.label')}
|
||||||
|
</label>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type={revealed ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealed((prev) => !prev)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={revealed ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||||
|
>
|
||||||
|
{revealed ? (
|
||||||
|
/* Eye-off inline SVG */
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||||
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
/* Eye inline SVG */
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center py-8 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<TabId>('inspect');
|
||||||
|
const [file, setFile] = useState<File | null>(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 <InspectTab {...props} />;
|
||||||
|
case 'split':
|
||||||
|
return <SplitTab {...props} />;
|
||||||
|
case 'merge':
|
||||||
|
return <MergeTab {...props} />;
|
||||||
|
case 'convert':
|
||||||
|
return <ConvertTab {...props} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shared input card */}
|
||||||
|
<div className="rounded-lg border border-border bg-card 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">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabChange(tab)}
|
||||||
|
className={`pb-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'border-b-2 border-primary text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`tabs.${tab}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Tab content card */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||||
|
{renderActiveTab()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -465,7 +465,51 @@
|
|||||||
"backToMarketplace": "Zurueck zum Marktplatz",
|
"backToMarketplace": "Zurueck zum Marktplatz",
|
||||||
"detailVersion": "Version {version}",
|
"detailVersion": "Version {version}",
|
||||||
"detailStatusActive": "Aktiviert fuer diesen Mandanten",
|
"detailStatusActive": "Aktiviert fuer diesen Mandanten",
|
||||||
"detailStatusInactive": "Nicht aktiviert",
|
"detailStatusInactive": "Nicht aktiviert"
|
||||||
"accessDenied": "Zugriff verweigert"
|
},
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"accessDenied": "Zugriff verweigert" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,7 +465,51 @@
|
|||||||
"backToMarketplace": "Back to Marketplace",
|
"backToMarketplace": "Back to Marketplace",
|
||||||
"detailVersion": "Version {version}",
|
"detailVersion": "Version {version}",
|
||||||
"detailStatusActive": "Activated for this tenant",
|
"detailStatusActive": "Activated for this tenant",
|
||||||
"detailStatusInactive": "Not activated",
|
"detailStatusInactive": "Not activated"
|
||||||
"accessDenied": "Access denied"
|
},
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"accessDenied": "Access denied" }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user