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
This commit is contained in:
@@ -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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user