feat: rollenbasierte SMTP-Statistik + Service-Aktionen

- handleServiceAction: nur superadmin darf Dienste stoppen/starten
- handleSMTPStatus: domain_admin bekommt tenant-gefilterte Stats
  (Domains, Mailanzahl, Speicher) statt globaler Daemon-Info
- Admin-Dashboard: SMTP-Kacheln, Systemauslastung, IP-Allowlist
  nur für superadmin; domain_admin sieht eigene Domain-Statistik
- Dienste-Tab: Aktions-Buttons nur für superadmin sichtbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 21:41:57 +01:00
parent db433e5c2e
commit 2dbddff0e2
2 changed files with 122 additions and 58 deletions
+36
View File
@@ -589,6 +589,35 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
// domain_admin: return only their tenant's email statistics (no global daemon info)
if sess != nil && !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read stats")
return
}
domains := []string{}
if tenantID != nil && s.tenantStore != nil {
if dd, derr := s.tenantStore.ListDomains(r.Context(), *tenantID); derr == nil {
for _, d := range dd {
domains = append(domains, d.Domain)
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"tenant_only": true,
"domains": domains,
"total_mails": stats["count"],
"total_bytes": stats["total_size"],
})
return
}
// superadmin: global daemon status
if s.smtpDaemon == nil { if s.smtpDaemon == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false}) writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false})
return return
@@ -1047,6 +1076,13 @@ func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
// Only superadmin may start/stop/restart services
sess := sessionFromCtx(r.Context())
if sess == nil || !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
writeError(w, http.StatusForbidden, "superadmin required")
return
}
name := r.PathValue("name") name := r.PathValue("name")
if !isAllowedService(name) { if !isAllowedService(name) {
writeError(w, http.StatusBadRequest, "unknown service") writeError(w, http.StatusBadRequest, "unknown service")
+86 -58
View File
@@ -673,59 +673,85 @@ export default function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* SMTP */} {/* SMTP — superadmin: globaler Daemon; domain_admin: nur eigene Domain-Statistik */}
<Card> {isSuperAdmin ? (
<CardContent className="pt-6 space-y-3"> <>
<div className="flex items-center justify-between"> <Card>
<span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span> <CardContent className="pt-6 space-y-3">
<Badge variant={smtpStatus?.running ? "default" : "destructive"}> <div className="flex items-center justify-between">
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"} <span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span>
</Badge> <Badge variant={smtpStatus?.running ? "default" : "destructive"}>
</div> {smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
<Separator /> </Badge>
{smtpStatus ? ( </div>
<div className="grid grid-cols-2 gap-1 text-sm"> <Separator />
<span className="text-muted-foreground">Adresse</span> {smtpStatus ? (
<span className="font-mono">{smtpStatus.bind}</span> <div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Domain</span> <span className="text-muted-foreground">Adresse</span>
<span className="font-mono">{smtpStatus.domain || ""}</span> <span className="font-mono">{smtpStatus.bind}</span>
<span className="text-muted-foreground">TLS</span> <span className="text-muted-foreground">Domain</span>
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span> <span className="font-mono">{smtpStatus.domain || ""}</span>
<span className="text-muted-foreground">Max. Größe</span> <span className="text-muted-foreground">TLS</span>
<span>{smtpStatus.max_size_mb > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span> <span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
<span className="text-muted-foreground">Max. Größe</span>
<span>{smtpStatus.max_size_mb > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span>
<span className="text-xs text-muted-foreground">seit letztem Start</span>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Empfangen</span>
<span className="font-semibold text-green-600">{smtpStatus.received}</span>
<span className="text-muted-foreground">Abgelehnt</span>
<span className="font-semibold text-red-500">{smtpStatus.rejected}</span>
<span className="text-muted-foreground">Letzte Mail</span>
<span className="text-xs">
{smtpStatus.last_mail_at
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
: ""}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">SMTP meine Domain(s)</span>
<Badge variant="secondary">Tenant</Badge>
</div> </div>
) : ( <Separator />
<p className="text-sm text-muted-foreground">Nicht erreichbar</p> {smtpStatus ? (
)} <div className="grid grid-cols-2 gap-1 text-sm">
</CardContent> <span className="text-muted-foreground">Domain(s)</span>
</Card> <span className="font-mono text-xs">
{smtpStatus.domains?.length > 0 ? smtpStatus.domains.join(", ") : ""}
{/* SMTP Statistik (nur live via SMTP-Daemon) */} </span>
<Card> <span className="text-muted-foreground">Archivierte Mails</span>
<CardContent className="pt-6 space-y-3"> <span className="font-semibold">{smtpStatus.total_mails?.toLocaleString("de-DE") ?? ""}</span>
<div className="flex items-center justify-between"> <span className="text-muted-foreground">Speicher</span>
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span> <span>{smtpStatus.total_bytes ? formatBytes(smtpStatus.total_bytes) : ""}</span>
<span className="text-xs text-muted-foreground">seit letztem Start</span> </div>
</div> ) : (
<Separator /> <p className="text-sm text-muted-foreground">Keine Daten</p>
{smtpStatus ? ( )}
<div className="grid grid-cols-2 gap-1 text-sm"> </CardContent>
<span className="text-muted-foreground">Empfangen</span> </Card>
<span className="font-semibold text-green-600">{smtpStatus.received}</span> )}
<span className="text-muted-foreground">Abgelehnt</span>
<span className="font-semibold text-red-500">{smtpStatus.rejected}</span>
<span className="text-muted-foreground">Letzte Mail</span>
<span className="text-xs">
{smtpStatus.last_mail_at
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
: ""}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
{/* Archiv-Speicher */} {/* Archiv-Speicher */}
<Card> <Card>
@@ -753,8 +779,8 @@ export default function AdminPage() {
</Card> </Card>
</div> </div>
{/* System Stats: CPU, RAM, Disks, Archivzeitraum */} {/* System Stats: nur für superadmin */}
<div className="space-y-4"> {isSuperAdmin && <div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3> <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
{!systemStats ? ( {!systemStats ? (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -885,10 +911,10 @@ export default function AdminPage() {
)} )}
</> </>
)} )}
</div> </div>}
{/* IP-Allowlist */} {/* IP-Allowlist — nur superadmin */}
{smtpStatus && smtpStatus.allowed_ips?.length > 0 && ( {isSuperAdmin && smtpStatus && smtpStatus.allowed_ips?.length > 0 && (
<Card> <Card>
<CardContent className="pt-6 space-y-2"> <CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span> <span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
@@ -978,7 +1004,7 @@ export default function AdminPage() {
<TableHead className="w-24">Autostart</TableHead> <TableHead className="w-24">Autostart</TableHead>
<TableHead className="w-28">Externer Zugriff</TableHead> <TableHead className="w-28">Externer Zugriff</TableHead>
<TableHead>Beschreibung</TableHead> <TableHead>Beschreibung</TableHead>
<TableHead className="w-72 text-right">Aktionen</TableHead> {isSuperAdmin && <TableHead className="w-72 text-right">Aktionen</TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -1029,6 +1055,7 @@ export default function AdminPage() {
<TableCell className="text-sm text-muted-foreground truncate max-w-xs"> <TableCell className="text-sm text-muted-foreground truncate max-w-xs">
{svc.description || ""} {svc.description || ""}
</TableCell> </TableCell>
{isSuperAdmin && (
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-1 flex-wrap"> <div className="flex justify-end gap-1 flex-wrap">
{isActive ? ( {isActive ? (
@@ -1102,6 +1129,7 @@ export default function AdminPage() {
)} )}
</div> </div>
</TableCell> </TableCell>
)}
</TableRow> </TableRow>
); );
})} })}