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:
sysops
2026-03-20 00:14:43 +01:00
parent 0e62b10bd4
commit 9e71af104f
8 changed files with 708 additions and 3 deletions
+212
View File
@@ -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&#39;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&#39;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&#39;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>
+5 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
export const metadata: Metadata = {
@@ -12,9 +13,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="de">
<html lang="de" suppressHydrationWarning>
<body className="antialiased min-h-screen bg-background text-foreground">
{children}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
+5 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { UserNav } from "@/components/UserNav";
import { ThemeToggle } from "@/components/theme-toggle";
interface NavbarProps {
username: string;
@@ -49,7 +50,10 @@ export function Navbar({ username, role }: NavbarProps) {
</Link>
)}
</div>
<UserNav username={username} role={role} />
<div className="flex items-center gap-2">
<ThemeToggle />
<UserNav username={username} role={role} />
</div>
</div>
</nav>
);
+16
View File
@@ -0,0 +1,16 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { useTheme } from "next-themes"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Design wechseln">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Design wechseln</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Hell
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dunkel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+61
View File
@@ -935,3 +935,64 @@ export async function createLabelRule(
export async function deleteLabelRule(id: number): Promise<void> {
return request<void>(`/api/admin/label-rules/${id}`, { method: "DELETE" });
}
// ── Certificate Management ────────────────────────────────────────────────
export interface CertInfo {
exists: boolean;
subject?: string;
issuer?: string;
not_before?: string;
not_after?: string;
dns_names?: string[];
ip_addresses?: string[];
fingerprint_sha256?: string;
is_self_signed?: boolean;
days_remaining?: number;
}
export interface SelfSignedRequest {
common_name: string;
dns_names: string[];
ip_addresses: string[];
validity_years: number;
}
export interface ACMERequest {
domain: string;
email: string;
}
export async function getCertInfo(): Promise<CertInfo> {
return request<CertInfo>("/api/admin/cert/info");
}
export async function uploadCert(cert: File, key: File): Promise<{ ok: boolean; message: string }> {
const form = new FormData();
form.append("cert", cert);
form.append("key", key);
const res = await fetch(`${API_BASE}/api/admin/cert/upload`, {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const body = await res.text();
throw new Error(body || `Upload failed: ${res.status}`);
}
return res.json();
}
export async function generateSelfSignedCert(req: SelfSignedRequest): Promise<CertInfo & { ok: boolean }> {
return request<CertInfo & { ok: boolean }>("/api/admin/cert/self-signed", {
method: "POST",
body: JSON.stringify(req),
});
}
export async function requestACMECert(req: ACMERequest): Promise<{ ok: boolean; output: string }> {
return request<{ ok: boolean; output: string }>("/api/admin/cert/acme", {
method: "POST",
body: JSON.stringify(req),
});
}