feat: Dark Mode + Zertifikat-Verwaltung im Superadmin
- Dark Mode: ThemeProvider (next-themes), ThemeToggle in Navbar (Hell/Dunkel/System) - Zertifikat-Tab (superadmin only): aktuelles Zertifikat anzeigen, Upload (cert+key), Self-Signed ausstellen, Let's Encrypt/ACME - Backend: /api/admin/cert/* Endpunkte (info, upload, self-signed, acme), nginx reload - HTTPS bereits live auf Server (self-signed RSA-4096, Port 443) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,11 @@ import {
|
||||
type TenantDomain,
|
||||
type MailLabel,
|
||||
type LabelRule,
|
||||
getCertInfo,
|
||||
uploadCert,
|
||||
generateSelfSignedCert,
|
||||
requestACMECert,
|
||||
type CertInfo,
|
||||
} from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -247,6 +252,27 @@ export default function AdminPage() {
|
||||
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
|
||||
const [ruleCreating, setRuleCreating] = useState(false);
|
||||
|
||||
// Certificate state
|
||||
const [certInfo, setCertInfo] = useState<CertInfo | null>(null);
|
||||
const [certLoading, setCertLoading] = useState(false);
|
||||
const [certError, setCertError] = useState("");
|
||||
const [certSuccess, setCertSuccess] = useState("");
|
||||
// Upload state
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||
const [certUploadLoading, setCertUploadLoading] = useState(false);
|
||||
// Self-signed state
|
||||
const [selfSignedCN, setSelfSignedCN] = useState("archivmail");
|
||||
const [selfSignedDNS, setSelfSignedDNS] = useState("archivmail");
|
||||
const [selfSignedIPs, setSelfSignedIPs] = useState("192.168.1.131");
|
||||
const [selfSignedYears, setSelfSignedYears] = useState("10");
|
||||
const [selfSignedLoading, setSelfSignedLoading] = useState(false);
|
||||
// ACME state
|
||||
const [acmeDomain, setAcmeDomain] = useState("");
|
||||
const [acmeEmail, setAcmeEmail] = useState("");
|
||||
const [acmeLoading, setAcmeLoading] = useState(false);
|
||||
const [acmeOutput, setAcmeOutput] = useState("");
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setDashLoading(true);
|
||||
try {
|
||||
@@ -306,6 +332,19 @@ export default function AdminPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCert = useCallback(async () => {
|
||||
setCertLoading(true);
|
||||
setCertError("");
|
||||
try {
|
||||
const info = await getCertInfo();
|
||||
setCertInfo(info);
|
||||
} catch (e) {
|
||||
setCertError(String(e));
|
||||
} finally {
|
||||
setCertLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleUploadFiles(files: File[]) {
|
||||
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
|
||||
if (valid.length === 0) {
|
||||
@@ -836,6 +875,7 @@ export default function AdminPage() {
|
||||
)}
|
||||
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||
</TabsList>
|
||||
@@ -2742,6 +2782,178 @@ export default function AdminPage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Zertifikat ── */}
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="cert" className="mt-4 space-y-6">
|
||||
{/* Aktuelles Zertifikat */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Aktuelles Zertifikat</h3>
|
||||
<Button variant="outline" size="sm" onClick={loadCert} disabled={certLoading}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
{certLoading && <div className="text-sm text-muted-foreground">Lade...</div>}
|
||||
{certError && <Alert variant="destructive"><AlertDescription>{certError}</AlertDescription></Alert>}
|
||||
{certSuccess && <Alert><AlertDescription>{certSuccess}</AlertDescription></Alert>}
|
||||
{certInfo && !certLoading && (
|
||||
certInfo.exists ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<span className="text-muted-foreground">Aussteller</span>
|
||||
<span>{certInfo.issuer}</span>
|
||||
<span className="text-muted-foreground">Subject</span>
|
||||
<span>{certInfo.subject}</span>
|
||||
<span className="text-muted-foreground">Gueltig bis</span>
|
||||
<span className={certInfo.days_remaining! < 30 ? "text-destructive font-medium" : ""}>
|
||||
{certInfo.not_after ? new Date(certInfo.not_after).toLocaleDateString("de-DE") : "--"}
|
||||
{" "}({certInfo.days_remaining} Tage)
|
||||
</span>
|
||||
<span className="text-muted-foreground">DNS-Namen</span>
|
||||
<span>{certInfo.dns_names?.join(", ") || "--"}</span>
|
||||
<span className="text-muted-foreground">IP-Adressen</span>
|
||||
<span>{certInfo.ip_addresses?.join(", ") || "--"}</span>
|
||||
<span className="text-muted-foreground">Typ</span>
|
||||
<span>{certInfo.is_self_signed ? "Selbstsigniert" : "CA-signiert"}</span>
|
||||
<span className="text-muted-foreground">SHA-256</span>
|
||||
<span className="font-mono text-xs break-all">{certInfo.fingerprint_sha256}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Kein Zertifikat gefunden unter /etc/ssl/archivmail/</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zertifikat hochladen */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Zertifikat hochladen</h3>
|
||||
<p className="text-sm text-muted-foreground">Eigenes CA-signiertes oder Let's Encrypt Zertifikat hochladen.</p>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Zertifikat (.crt / .pem)</Label>
|
||||
<Input type="file" accept=".crt,.pem,.cer" onChange={e => setCertFile(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Privater Schluessel (.key / .pem)</Label>
|
||||
<Input type="file" accept=".key,.pem" onChange={e => setKeyFile(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!certFile || !keyFile) return;
|
||||
setCertUploadLoading(true); setCertError(""); setCertSuccess("");
|
||||
try {
|
||||
const res = await uploadCert(certFile, keyFile);
|
||||
setCertSuccess(res.message);
|
||||
loadCert();
|
||||
} catch(e) { setCertError(String(e)); }
|
||||
finally { setCertUploadLoading(false); }
|
||||
}}
|
||||
disabled={!certFile || !keyFile || certUploadLoading}
|
||||
>
|
||||
{certUploadLoading ? "Hochladen..." : "Hochladen & nginx neu laden"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Self-Signed generieren */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Self-Signed Zertifikat ausstellen</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Common Name</Label>
|
||||
<Input value={selfSignedCN} onChange={e => setSelfSignedCN(e.target.value)} placeholder="archivmail" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Gueltigkeit</Label>
|
||||
<Select value={selfSignedYears} onValueChange={setSelfSignedYears}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 Jahr</SelectItem>
|
||||
<SelectItem value="5">5 Jahre</SelectItem>
|
||||
<SelectItem value="10">10 Jahre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>DNS-Namen (kommagetrennt)</Label>
|
||||
<Input value={selfSignedDNS} onChange={e => setSelfSignedDNS(e.target.value)} placeholder="archivmail,mail.intern" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>IP-Adressen (kommagetrennt)</Label>
|
||||
<Input value={selfSignedIPs} onChange={e => setSelfSignedIPs(e.target.value)} placeholder="192.168.1.131" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setSelfSignedLoading(true); setCertError(""); setCertSuccess("");
|
||||
try {
|
||||
const res = await generateSelfSignedCert({
|
||||
common_name: selfSignedCN,
|
||||
dns_names: selfSignedDNS.split(",").map(s => s.trim()).filter(Boolean),
|
||||
ip_addresses: selfSignedIPs.split(",").map(s => s.trim()).filter(Boolean),
|
||||
validity_years: parseInt(selfSignedYears),
|
||||
});
|
||||
setCertSuccess("Zertifikat ausgestellt und nginx neu geladen.");
|
||||
setCertInfo(res);
|
||||
} catch(e) { setCertError(String(e)); }
|
||||
finally { setSelfSignedLoading(false); }
|
||||
}}
|
||||
disabled={selfSignedLoading || !selfSignedCN}
|
||||
>
|
||||
{selfSignedLoading ? "Generiere..." : "Ausstellen & nginx neu laden"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ACME / Let's Encrypt */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Let's Encrypt / ACME</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oeffentlich erreichbare Domain erforderlich (Port 80 muss von aussen erreichbar sein).
|
||||
certbot muss auf dem Server installiert sein.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Domain</Label>
|
||||
<Input value={acmeDomain} onChange={e => setAcmeDomain(e.target.value)} placeholder="mail.example.com" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>E-Mail (fuer Let's Encrypt)</Label>
|
||||
<Input value={acmeEmail} onChange={e => setAcmeEmail(e.target.value)} placeholder="admin@example.com" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
{acmeOutput && (
|
||||
<pre className="text-xs bg-muted p-3 rounded overflow-auto max-h-40 whitespace-pre-wrap">{acmeOutput}</pre>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setAcmeLoading(true); setCertError(""); setCertSuccess(""); setAcmeOutput("");
|
||||
try {
|
||||
const res = await requestACMECert({ domain: acmeDomain, email: acmeEmail });
|
||||
setCertSuccess("Let's Encrypt Zertifikat ausgestellt.");
|
||||
setAcmeOutput(res.output);
|
||||
loadCert();
|
||||
} catch(e) {
|
||||
setCertError(String(e));
|
||||
}
|
||||
finally { setAcmeLoading(false); }
|
||||
}}
|
||||
disabled={acmeLoading || !acmeDomain || !acmeEmail}
|
||||
>
|
||||
{acmeLoading ? "Laeuft (kann ~30s dauern)..." : "Zertifikat via Let's Encrypt anfordern"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="modules" className="mt-4">
|
||||
<ModulesTab />
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user