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:
@@ -0,0 +1,365 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
certDir = "/etc/ssl/archivmail"
|
||||||
|
certPath = "/etc/ssl/archivmail/archivmail.crt"
|
||||||
|
keyPath = "/etc/ssl/archivmail/archivmail.key"
|
||||||
|
)
|
||||||
|
|
||||||
|
// certInfoResponse holds the parsed certificate metadata returned by GET /api/admin/cert/info.
|
||||||
|
type certInfoResponse struct {
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Issuer string `json:"issuer,omitempty"`
|
||||||
|
NotBefore time.Time `json:"not_before,omitempty"`
|
||||||
|
NotAfter time.Time `json:"not_after,omitempty"`
|
||||||
|
DNSNames []string `json:"dns_names,omitempty"`
|
||||||
|
IPAddresses []string `json:"ip_addresses,omitempty"`
|
||||||
|
FingerprintSHA256 string `json:"fingerprint_sha256,omitempty"`
|
||||||
|
IsSelfSigned bool `json:"is_self_signed,omitempty"`
|
||||||
|
DaysRemaining int `json:"days_remaining,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCertInfo returns metadata about the currently installed TLS certificate.
|
||||||
|
// GET /api/admin/cert/info
|
||||||
|
func (s *Server) handleCertInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
info, err := readCertInfo()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read certificate: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCertInfo reads and parses the certificate file at certPath.
|
||||||
|
// Returns {"exists": false} when the file is absent; an error only on read/parse failures.
|
||||||
|
func readCertInfo() (*certInfoResponse, error) {
|
||||||
|
raw, err := os.ReadFile(certPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &certInfoResponse{Exists: false}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read cert file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(raw)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found in certificate file")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ips := make([]string, 0, len(cert.IPAddresses))
|
||||||
|
for _, ip := range cert.IPAddresses {
|
||||||
|
ips = append(ips, ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(cert.Raw)
|
||||||
|
parts := make([]string, 32)
|
||||||
|
for i, b := range sum {
|
||||||
|
parts[i] = fmt.Sprintf("%02X", b)
|
||||||
|
}
|
||||||
|
fingerprint := strings.Join(parts, ":")
|
||||||
|
|
||||||
|
isSelfSigned := cert.Subject.String() == cert.Issuer.String()
|
||||||
|
daysRemaining := int(time.Until(cert.NotAfter).Hours() / 24)
|
||||||
|
|
||||||
|
dnsNames := cert.DNSNames
|
||||||
|
if dnsNames == nil {
|
||||||
|
dnsNames = []string{}
|
||||||
|
}
|
||||||
|
if ips == nil {
|
||||||
|
ips = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &certInfoResponse{
|
||||||
|
Exists: true,
|
||||||
|
Subject: cert.Subject.String(),
|
||||||
|
Issuer: cert.Issuer.String(),
|
||||||
|
NotBefore: cert.NotBefore,
|
||||||
|
NotAfter: cert.NotAfter,
|
||||||
|
DNSNames: dnsNames,
|
||||||
|
IPAddresses: ips,
|
||||||
|
FingerprintSHA256: fingerprint,
|
||||||
|
IsSelfSigned: isSelfSigned,
|
||||||
|
DaysRemaining: daysRemaining,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCertUpload validates and installs an uploaded PEM certificate + key pair.
|
||||||
|
// POST /api/admin/cert/upload (multipart/form-data fields: cert, key)
|
||||||
|
func (s *Server) handleCertUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Limit to 4 MB to prevent abuse; real PEM files are a few KB at most.
|
||||||
|
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM, err := readMultipartField(r, "cert")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing or unreadable 'cert' field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyPEM, err := readMultipartField(r, "key")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing or unreadable 'key' field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that cert and key form a valid pair before touching disk.
|
||||||
|
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("certificate and key do not match: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeCertFiles(certPEM, keyPEM); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write certificate files: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := reloadNginx(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"message": "Zertifikat gespeichert und nginx neu geladen",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCertSelfSigned generates a new self-signed RSA-4096 certificate using Go's crypto/x509.
|
||||||
|
// POST /api/admin/cert/self-signed
|
||||||
|
func (s *Server) handleCertSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
DNSNames []string `json:"dns_names"`
|
||||||
|
IPAddresses []string `json:"ip_addresses"`
|
||||||
|
ValidityYears int `json:"validity_years"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.CommonName == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "common_name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ValidityYears <= 0 || req.ValidityYears > 30 {
|
||||||
|
req.ValidityYears = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate IP addresses.
|
||||||
|
ips := make([]net.IP, 0, len(req.IPAddresses))
|
||||||
|
for _, addr := range req.IPAddresses {
|
||||||
|
ip := net.ParseIP(addr)
|
||||||
|
if ip == nil {
|
||||||
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid IP address: %s", addr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate RSA-4096 private key.
|
||||||
|
privKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("key generation failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "serial number generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notBefore := time.Now().UTC()
|
||||||
|
notAfter := notBefore.AddDate(req.ValidityYears, 0, 0)
|
||||||
|
|
||||||
|
dnsNames := req.DNSNames
|
||||||
|
if dnsNames == nil {
|
||||||
|
dnsNames = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: req.CommonName,
|
||||||
|
},
|
||||||
|
DNSNames: dnsNames,
|
||||||
|
IPAddresses: ips,
|
||||||
|
NotBefore: notBefore,
|
||||||
|
NotAfter: notAfter,
|
||||||
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &privKey.PublicKey, privKey)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("certificate creation failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privKey)})
|
||||||
|
|
||||||
|
if err := writeCertFiles(certPEM, keyPEM); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write certificate files: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := reloadNginx(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := readCertInfo()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "certificate written but could not read back info")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"cert": info,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCertACME runs certbot to obtain a Let's Encrypt certificate via the NGINX plugin.
|
||||||
|
// POST /api/admin/cert/acme
|
||||||
|
func (s *Server) handleCertACME(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Domain == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "domain is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "email is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify certbot is available before attempting anything.
|
||||||
|
certbotPath, err := exec.LookPath("certbot")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "certbot is not installed on this server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(certbotPath,
|
||||||
|
"certonly", "--nginx",
|
||||||
|
"-d", req.Domain,
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
"-m", req.Email,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the issued certificates from /etc/letsencrypt/live/<domain>/ to certDir.
|
||||||
|
lePath := filepath.Join("/etc/letsencrypt/live", req.Domain)
|
||||||
|
certPEM, err := os.ReadFile(filepath.Join(lePath, "fullchain.pem"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot succeeded but could not read issued cert: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(filepath.Join(lePath, "privkey.pem"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot succeeded but could not read issued key: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeCertFiles(certPEM, keyPEM); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to copy certificates: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if nginxOut, err := reloadNginx(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, nginxOut))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"output": string(out),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers ---
|
||||||
|
|
||||||
|
// writeCertFiles creates certDir if necessary and writes cert (0644) + key (0600).
|
||||||
|
func writeCertFiles(certPEM, keyPEM []byte) error {
|
||||||
|
if err := os.MkdirAll(certDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create cert dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
||||||
|
return fmt.Errorf("write cert: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||||
|
return fmt.Errorf("write key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadNginx tests nginx config validity, then reloads the service.
|
||||||
|
// Returns combined stdout+stderr output and any error.
|
||||||
|
func reloadNginx() (string, error) {
|
||||||
|
testCmd := exec.Command("nginx", "-t")
|
||||||
|
testOut, err := testCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(testOut), fmt.Errorf("nginx config test failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadCmd := exec.Command("systemctl", "reload", "nginx")
|
||||||
|
reloadOut, err := reloadCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(reloadOut), fmt.Errorf("systemctl reload nginx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(testOut) + string(reloadOut), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMultipartField reads all bytes from a named file field in a parsed multipart form.
|
||||||
|
func readMultipartField(r *http.Request, field string) ([]byte, error) {
|
||||||
|
f, _, err := r.FormFile(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return io.ReadAll(f)
|
||||||
|
}
|
||||||
@@ -198,6 +198,12 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable))
|
s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable))
|
||||||
s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token
|
s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token
|
||||||
s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset))
|
s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset))
|
||||||
|
|
||||||
|
// Certificate management routes (superadmin only)
|
||||||
|
s.mux.HandleFunc("GET /api/admin/cert/info", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertInfo)))
|
||||||
|
s.mux.HandleFunc("POST /api/admin/cert/upload", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertUpload)))
|
||||||
|
s.mux.HandleFunc("POST /api/admin/cert/self-signed", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertSelfSigned)))
|
||||||
|
s.mux.HandleFunc("POST /api/admin/cert/acme", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertACME)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler.
|
// ServeHTTP implements http.Handler.
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ import {
|
|||||||
type TenantDomain,
|
type TenantDomain,
|
||||||
type MailLabel,
|
type MailLabel,
|
||||||
type LabelRule,
|
type LabelRule,
|
||||||
|
getCertInfo,
|
||||||
|
uploadCert,
|
||||||
|
generateSelfSignedCert,
|
||||||
|
requestACMECert,
|
||||||
|
type CertInfo,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -247,6 +252,27 @@ export default function AdminPage() {
|
|||||||
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
|
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
|
||||||
const [ruleCreating, setRuleCreating] = useState(false);
|
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 () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
setDashLoading(true);
|
setDashLoading(true);
|
||||||
try {
|
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[]) {
|
async function handleUploadFiles(files: File[]) {
|
||||||
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
|
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
|
||||||
if (valid.length === 0) {
|
if (valid.length === 0) {
|
||||||
@@ -836,6 +875,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="security">Security</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="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -2742,6 +2782,178 @@ export default function AdminPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="modules" className="mt-4">
|
||||||
<ModulesTab />
|
<ModulesTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
+5
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -12,9 +13,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body className="antialiased min-h-screen bg-background text-foreground">
|
<body className="antialiased min-h-screen bg-background text-foreground">
|
||||||
{children}
|
<ThemeProvider>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { UserNav } from "@/components/UserNav";
|
import { UserNav } from "@/components/UserNav";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -49,7 +50,10 @@ export function Navbar({ username, role }: NavbarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<UserNav username={username} role={role} />
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<UserNav username={username} role={role} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -935,3 +935,64 @@ export async function createLabelRule(
|
|||||||
export async function deleteLabelRule(id: number): Promise<void> {
|
export async function deleteLabelRule(id: number): Promise<void> {
|
||||||
return request<void>(`/api/admin/label-rules/${id}`, { method: "DELETE" });
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user