42a41f77d0
- 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
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
'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>
|
|
);
|
|
}
|