chore: admin/page.tsx in Einzelkomponenten aufteilen (3917 → 1304 Zeilen)

- Tab-Sektionen → src/components/admin/tabs/ (11 Dateien)
- Dialoge → src/components/admin/ (TenantLDAPDialog, UserDialogs)
- Keine Verhaltensänderungen, TypeScript fehlerfrei

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-20 12:30:16 +01:00
parent 38f8cdddc7
commit bc4a98de0d
15 changed files with 3940 additions and 2905 deletions
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { features, type Feature } from "@/data/features";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const statusColors: Record<string, string> = {
"Planned": "bg-gray-100 text-gray-700",
"In Progress": "bg-yellow-100 text-yellow-800",
"In Review": "bg-blue-100 text-blue-800",
"Deployed": "bg-green-100 text-green-800",
};
const statusCounts = (list: Feature[]) => ({
total: list.length,
planned: list.filter((f) => f.status === "Planned").length,
inProgress: list.filter((f) => f.status === "In Progress").length,
inReview: list.filter((f) => f.status === "In Review").length,
deployed: list.filter((f) => f.status === "Deployed").length,
});
export function ModulesTab() {
const counts = statusCounts(features);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Modulübersicht</h2>
{/* Summary bar */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
].map((s) => (
<Card key={s.label}>
<CardContent className="p-4 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{s.label}</span>
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
{s.value}
</span>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>Feature</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-24 text-center">Frontend</TableHead>
<TableHead className="w-24 text-center">Backend</TableHead>
<TableHead className="w-32">Aktualisiert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{features.map((f) => (
<TableRow key={f.id}>
<TableCell className="font-mono text-xs text-muted-foreground">
{f.id}
</TableCell>
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
{f.status}
</span>
</TableCell>
<TableCell className="text-center">
{f.frontend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-center">
{f.backend
? <span className="text-green-600 font-bold"></span>
: <span className="text-muted-foreground"></span>
}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{f.lastUpdated}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getAdminTenantLDAPConfig,
saveAdminTenantLDAPConfig,
deleteAdminTenantLDAPConfig,
testAdminTenantLDAPConfig,
type TenantLDAPConfig,
type LDAPTestResult,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TenantLDAPDialogProps {
tenantID: number;
onClose: () => void;
}
export function TenantLDAPDialog({ tenantID, onClose }: TenantLDAPDialogProps) {
const [config, setConfig] = useState<TenantLDAPConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState("");
const [testResult, setTestResult] = useState<LDAPTestResult | null>(null);
const [changePassword, setChangePassword] = useState(false);
const [form, setForm] = useState<TenantLDAPConfig>({
enabled: false,
url: "ldap://",
bind_dn: "",
bind_password: "",
base_dn: "",
user_filter: "(sAMAccountName=%s)",
tls: false,
tls_skip_verify: false,
default_role: "user",
group_mappings: [],
});
const loadConfig = useCallback(async () => {
setLoading(true);
setError("");
try {
const cfg = await getAdminTenantLDAPConfig(tenantID);
if (cfg) {
setConfig(cfg);
setForm({ ...cfg, bind_password: "" });
setChangePassword(false);
}
} catch {
setError("LDAP-Konfiguration konnte nicht geladen werden.");
} finally {
setLoading(false);
}
}, [tenantID]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
try {
const payload: Partial<TenantLDAPConfig> = { ...form };
if (!changePassword) {
delete payload.bind_password;
}
await saveAdminTenantLDAPConfig(tenantID, payload);
await loadConfig();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
async function handleTest() {
setTesting(true);
setError("");
setTestResult(null);
try {
const payload = config
? { use_saved: true }
: { use_saved: false, ...form };
const result = await testAdminTenantLDAPConfig(tenantID, payload as Parameters<typeof testAdminTenantLDAPConfig>[1]);
setTestResult(result);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Test fehlgeschlagen.");
} finally {
setTesting(false);
}
}
async function handleDelete() {
setSaving(true);
setError("");
try {
await deleteAdminTenantLDAPConfig(tenantID);
setConfig(null);
setForm({
enabled: false, url: "ldap://", bind_dn: "", bind_password: "",
base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false,
tls_skip_verify: false, default_role: "user", group_mappings: [],
});
setTestResult(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Löschen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>LDAP-Konfiguration (Mandant #{tenantID})</DialogTitle>
<DialogDescription>LDAP-Server für diesen Mandanten konfigurieren.</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={handleSave} className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`atldap-url-${tenantID}`}>Server-URL</Label>
<Input
id={`atldap-url-${tenantID}`}
placeholder="ldap://server:389 oder ldaps://server:636"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-bind-dn-${tenantID}`}>Bind-DN (Service-Account)</Label>
<Input
id={`atldap-bind-dn-${tenantID}`}
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={form.bind_dn}
onChange={(e) => setForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-pw-${tenantID}`}>Bind-Passwort</Label>
{config && !changePassword ? (
<div className="flex gap-2">
<Input id={`atldap-pw-${tenantID}`} type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id={`atldap-pw-${tenantID}`}
type="password"
placeholder="Neues Passwort eingeben"
value={form.bind_password}
onChange={(e) => setForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-base-dn-${tenantID}`}>Base-DN</Label>
<Input
id={`atldap-base-dn-${tenantID}`}
placeholder="DC=example,DC=com"
value={form.base_dn}
onChange={(e) => setForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-filter-${tenantID}`}>User-Filter</Label>
<Input
id={`atldap-filter-${tenantID}`}
placeholder="(sAMAccountName=%s)"
value={form.user_filter}
onChange={(e) => setForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`atldap-role-${tenantID}`}>Standard-Rolle</Label>
<Select
value={form.default_role}
onValueChange={(v) => setForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id={`atldap-role-${tenantID}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={form.tls}
onChange={(e) => setForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={form.tls_skip_verify}
onChange={(e) => setForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{form.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings -- superadmin per tenant: bis domain_admin */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufuegen
</Button>
</div>
{form.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{form.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...form.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...form.group_mappings];
gms[i] = { ...gms[i], role: v };
setForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = form.group_mappings.filter((_, j) => j !== i);
setForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
{/* Test result */}
{testResult && (
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={testResult.ok ? "default" : "destructive"}>
{testResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{testResult.message}</span>
{testResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{testResult.latency_ms} ms</span>
)}
</div>
{testResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{testResult.server_info}</p>
)}
{testResult.users_found > 0 && (
<p className="text-sm">{testResult.users_found} Benutzer gefunden</p>
)}
{testResult.error_detail && (
<p className="text-xs text-destructive font-mono">{testResult.error_detail}</p>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleTest} disabled={testing || saving}>
{testing ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={saving || testing}>
{saving ? "Speichern..." : "Speichern"}
</Button>
{config && (
<Button
type="button"
variant="destructive"
disabled={saving}
onClick={handleDelete}
>
Konfiguration löschen
</Button>
)}
</div>
{config && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {config.updated_at ? new Date(config.updated_at).toLocaleString("de-DE") : ""}
{config.updated_by ? ` von ${config.updated_by}` : ""}
</p>
)}
</form>
)}
</DialogContent>
</Dialog>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { type User } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ResetPasswordDialogProps {
open: boolean;
onClose: () => void;
value: string;
setValue: (v: string) => void;
error: string;
loading: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export function ResetPasswordDialog({
open,
onClose,
value,
setValue,
error,
loading,
onSubmit,
}: ResetPasswordDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passwort zurücksetzen</DialogTitle>
<DialogDescription>
Neues Passwort für den Benutzer festlegen.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
required
minLength={8}
placeholder="Mindestens 8 Zeichen"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
interface DeleteUserDialogProps {
user: User | null;
onClose: () => void;
deleteActionLoading: "deactivate" | "delete" | null;
deleteDialogError: string;
onDeactivate: () => void;
onDelete: () => void;
}
export function DeleteUserDialog({
user,
onClose,
deleteActionLoading,
deleteDialogError,
onDeactivate,
onDelete,
}: DeleteUserDialogProps) {
return (
<Dialog open={user !== null} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer entfernen</DialogTitle>
<DialogDescription>
Was soll mit dem Konto <strong>{user?.username}</strong> passieren?
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
<strong>Hinweis (GoBD):</strong> E-Mails bleiben unabhängig von dieser Aktion im Archiv erhalten. Die gesetzliche Aufbewahrungspflicht besteht auch nach Ausscheiden des Mitarbeiters.
</div>
<div className="space-y-3">
<div className="rounded-md border p-3">
<p className="font-medium text-sm">Konto deaktivieren (empfohlen)</p>
<p className="text-xs text-muted-foreground mt-1">
Login wird gesperrt. Konto und IMAP-Verbindungen bleiben erhalten und können reaktiviert werden.
</p>
</div>
<div className="rounded-md border border-destructive/30 p-3">
<p className="font-medium text-sm text-destructive">Konto endgültig löschen</p>
<p className="text-xs text-muted-foreground mt-1">
Account und alle IMAP-Verbindungen werden dauerhaft entfernt. Nicht rückgängig zu machen.
</p>
</div>
</div>
{deleteDialogError && (
<p className="text-sm text-destructive">{deleteDialogError}</p>
)}
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={onClose} disabled={deleteActionLoading !== null}>
Abbrechen
</Button>
<Button
variant="outline"
onClick={onDeactivate}
disabled={deleteActionLoading !== null || user?.active === false}
>
{deleteActionLoading === "deactivate" ? "Wird deaktiviert..." : "Deaktivieren"}
</Button>
<Button
variant="destructive"
onClick={onDelete}
disabled={deleteActionLoading !== null}
>
{deleteActionLoading === "delete" ? "Wird gelöscht..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { type AuditEntry } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const AUDIT_PAGE_SIZE = 25;
interface AuditTabProps {
auditEntries: AuditEntry[];
auditTotal: number;
auditPage: number;
auditLoading: boolean;
onLoadAudit: (page: number) => void;
}
export function AuditTab({
auditEntries,
auditTotal,
auditPage,
auditLoading,
onLoadAudit,
}: AuditTabProps) {
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
return (
<div className="mt-4">
<h2 className="mb-4 text-lg font-semibold">Audit-Log</h2>
{auditLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : auditEntries.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Audit-Eintraege vorhanden.
</CardContent>
</Card>
) : (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitstempel</TableHead>
<TableHead>Ereignis</TableHead>
<TableHead>Benutzer</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditEntries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap">
{new Date(entry.timestamp).toLocaleString("de-DE")}
</TableCell>
<TableCell>
<Badge variant="outline">
{entry.event_type}
</Badge>
</TableCell>
<TableCell>{entry.username}</TableCell>
<TableCell className="max-w-xs truncate">
{entry.detail}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{auditTotalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={auditPage <= 1}
onClick={() => onLoadAudit(auditPage - 1)}
>
Zurueck
</Button>
<span className="text-sm text-muted-foreground">
Seite {auditPage} von {auditTotalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={auditPage >= auditTotalPages}
onClick={() => onLoadAudit(auditPage + 1)}
>
Weiter
</Button>
</div>
)}
</>
)}
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { type CertInfo, uploadCert, generateSelfSignedCert, requestACMECert } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface CertTabProps {
certInfo: CertInfo | null;
certLoading: boolean;
certError: string;
certSuccess: string;
certFile: File | null;
setCertFile: (f: File | null) => void;
keyFile: File | null;
setKeyFile: (f: File | null) => void;
certUploadLoading: boolean;
setCertUploadLoading: (v: boolean) => void;
selfSignedCN: string;
setSelfSignedCN: (v: string) => void;
selfSignedDNS: string;
setSelfSignedDNS: (v: string) => void;
selfSignedIPs: string;
setSelfSignedIPs: (v: string) => void;
selfSignedYears: string;
setSelfSignedYears: (v: string) => void;
selfSignedLoading: boolean;
setSelfSignedLoading: (v: boolean) => void;
acmeDomain: string;
setAcmeDomain: (v: string) => void;
acmeEmail: string;
setAcmeEmail: (v: string) => void;
acmeLoading: boolean;
setAcmeLoading: (v: boolean) => void;
acmeOutput: string;
setAcmeOutput: (v: string) => void;
setCertError: (v: string) => void;
setCertSuccess: (v: string) => void;
setCertInfo: (info: CertInfo) => void;
onLoadCert: () => void;
}
export function CertTab({
certInfo,
certLoading,
certError,
certSuccess,
certFile,
setCertFile,
keyFile,
setKeyFile,
certUploadLoading,
setCertUploadLoading,
selfSignedCN,
setSelfSignedCN,
selfSignedDNS,
setSelfSignedDNS,
selfSignedIPs,
setSelfSignedIPs,
selfSignedYears,
setSelfSignedYears,
selfSignedLoading,
setSelfSignedLoading,
acmeDomain,
setAcmeDomain,
acmeEmail,
setAcmeEmail,
acmeLoading,
setAcmeLoading,
acmeOutput,
setAcmeOutput,
setCertError,
setCertSuccess,
setCertInfo,
onLoadCert,
}: CertTabProps) {
return (
<div 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={onLoadCert} 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);
onLoadCert();
} 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);
onLoadCert();
} 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>
</div>
);
}
+394
View File
@@ -0,0 +1,394 @@
"use client";
import {
type SMTPStatus,
type StorageStats,
type SystemStats,
} from "@/lib/api";
import { type User } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
interface DashboardTabProps {
isSuperAdmin: boolean;
smtpStatus: SMTPStatus | null;
storageStats: StorageStats | null;
systemStats: SystemStats | null;
apiOnline: boolean | null;
dashLoading: boolean;
dashRefreshed: Date | null;
countdown: number;
users: User[];
usersLoading: boolean;
onRefresh: () => void;
}
export function DashboardTab({
isSuperAdmin,
smtpStatus,
storageStats,
systemStats,
apiOnline,
dashLoading,
dashRefreshed,
countdown,
users,
usersLoading,
onRefresh,
}: DashboardTabProps) {
return (
<>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemstatus</h2>
<div className="flex items-center gap-3">
{dashRefreshed && (
<span className="text-xs text-muted-foreground">
{dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s
</span>
)}
<Button variant="outline" size="sm" onClick={onRefresh} disabled={dashLoading}>
{dashLoading ? "..." : "Jetzt aktualisieren"}
</Button>
</div>
</div>
{dashLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-lg" />
))}
</div>
) : (
<>
{/* Status-Kacheln */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* API */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">REST API</span>
<Badge variant={apiOnline ? "default" : "destructive"}>
{apiOnline ? "Online" : "Offline"}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">:8080</span>
<span className="text-muted-foreground">Protokoll</span>
<span>HTTP</span>
</div>
</CardContent>
</Card>
{/* SMTP — superadmin: globaler Daemon; domain_admin: nur eigene Domain-Statistik */}
{isSuperAdmin ? (
<>
<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-Daemon</span>
<Badge variant={smtpStatus?.running ? "default" : "destructive"}>
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
</Badge>
</div>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Adresse</span>
<span className="font-mono">{smtpStatus.bind}</span>
<span className="text-muted-foreground">Domain</span>
<span className="font-mono">{smtpStatus.domain || ""}</span>
<span className="text-muted-foreground">TLS</span>
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
<span className="text-muted-foreground">Max. Größe</span>
<span>{(smtpStatus.max_size_mb ?? 0) > 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 ?? 0}</span>
<span className="text-muted-foreground">Abgelehnt</span>
<span className="font-semibold text-red-500">{smtpStatus.rejected ?? 0}</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>
<Separator />
{smtpStatus ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Domain(s)</span>
<span className="font-mono text-xs">
{(smtpStatus.domains?.length ?? 0) > 0 ? smtpStatus.domains!.join(", ") : ""}
</span>
<span className="text-muted-foreground">Archivierte Mails</span>
<span className="font-semibold">{smtpStatus.total_mails?.toLocaleString("de-DE") ?? ""}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{smtpStatus.total_bytes ? formatBytes(smtpStatus.total_bytes) : ""}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
)}
{/* Archiv-Speicher */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archiv gesamt</span>
{storageStats && (
<Badge variant="secondary" className="font-mono text-xs">
{storageStats.total_mails} Mails
</Badge>
)}
</div>
<Separator />
{storageStats ? (
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">E-Mails</span>
<span className="font-semibold">{storageStats.total_mails.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Speicher</span>
<span>{formatBytes(storageStats.total_bytes)}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Daten</p>
)}
</CardContent>
</Card>
</div>
{/* System Stats: nur für superadmin */}
{isSuperAdmin && <div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
{!systemStats ? (
<Alert variant="destructive">
<AlertDescription>
Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt <code className="font-mono">/api/admin/system/stats</code> erreichbar ist.
</AlertDescription>
</Alert>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* CPU */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">CPU Load Average</span>
<Badge variant="secondary">{systemStats.cpu.num_cpu} CPU(s)</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">1 min</span>
<span className="font-semibold">{systemStats.cpu.load1.toFixed(2)}</span>
<span className="text-muted-foreground">5 min</span>
<span className="font-semibold">{systemStats.cpu.load5.toFixed(2)}</span>
<span className="text-muted-foreground">15 min</span>
<span className="font-semibold">{systemStats.cpu.load15.toFixed(2)}</span>
</div>
</CardContent>
</Card>
{/* RAM */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Arbeitsspeicher</span>
<Badge variant={systemStats.ram.used_pct > 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}>
{systemStats.ram.used_pct.toFixed(1)}%
</Badge>
</div>
<Separator />
<div className="space-y-2">
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${systemStats.ram.used_pct > 90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(systemStats.ram.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(systemStats.ram.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(systemStats.ram.free_bytes)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Archivzeitraum */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Archivzeitraum</span>
</div>
<Separator />
{systemStats.archive.first_mail || systemStats.archive.last_mail ? (
<div className="space-y-2 text-sm">
{systemStats.archive.first_mail && (
<div>
<span className="text-xs text-muted-foreground block">Älteste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.first_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.first_mail.subject || "(kein Betreff)"}</span>
</div>
)}
{systemStats.archive.last_mail && (
<div className="pt-1 border-t">
<span className="text-xs text-muted-foreground block">Neueste Mail</span>
<span className="font-semibold">{new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")}</span>
<span className="block text-muted-foreground truncate">{systemStats.archive.last_mail.from || ""}</span>
<span className="block text-xs truncate">{systemStats.archive.last_mail.subject || "(kein Betreff)"}</span>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Archiv leer</p>
)}
</CardContent>
</Card>
</div>
{/* Festplatten */}
{systemStats.disks.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{systemStats.disks.map((disk) => (
<Card key={disk.mount}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium font-mono">{disk.mount}</span>
<Badge variant={disk.used_pct > 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}>
{disk.used_pct.toFixed(1)}%
</Badge>
</div>
<div className="h-2 w-full rounded-full bg-secondary">
<div
className={`h-2 rounded-full ${disk.used_pct > 90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`}
style={{ width: `${Math.min(disk.used_pct, 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Belegt</span>
<span>{formatBytes(disk.used_bytes)}</span>
<span className="text-muted-foreground">Gesamt</span>
<span>{formatBytes(disk.total_bytes)}</span>
<span className="text-muted-foreground">Frei</span>
<span>{formatBytes(disk.free_bytes)}</span>
<span className="text-muted-foreground">Dateisystem</span>
<span className="font-mono text-xs">{disk.fstype}</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
</>
)}
</div>}
{/* IP-Allowlist — nur superadmin */}
{isSuperAdmin && smtpStatus && (smtpStatus.allowed_ips?.length ?? 0) > 0 && (
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
<Separator />
<div className="flex flex-wrap gap-2 pt-1">
{smtpStatus.allowed_ips!.map((ip) => (
<Badge key={ip} variant="outline" className="font-mono">
{ip}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Benutzerübersicht */}
<Card>
<CardContent className="pt-6 space-y-2">
<span className="text-sm font-medium text-muted-foreground">Benutzer</span>
<Separator />
{usersLoading ? (
<Skeleton className="h-8 w-full" />
) : (
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<span>
<span className="font-semibold">{users.filter(u => u.active).length}</span>
<span className="text-muted-foreground ml-1">aktiv</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "admin").length}</span>
<span className="text-muted-foreground ml-1">Admin</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "auditor").length}</span>
<span className="text-muted-foreground ml-1">Auditor</span>
</span>
<span>
<span className="font-semibold">{users.filter(u => u.role === "user").length}</span>
<span className="text-muted-foreground ml-1">User</span>
</span>
</div>
)}
</CardContent>
</Card>
{!smtpStatus && (
<Alert variant="destructive">
<AlertDescription>
SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft.
</AlertDescription>
</Alert>
)}
</>
)}
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { type UploadJob } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface ImportTabProps {
uploadDragging: boolean;
setUploadDragging: (v: boolean) => void;
uploadJob: UploadJob | null;
uploadError: string;
uploadLoading: boolean;
onUploadFiles: (files: File[]) => void;
}
export function ImportTab({
uploadDragging,
setUploadDragging,
uploadJob,
uploadError,
uploadLoading,
onUploadFiles,
}: ImportTabProps) {
return (
<div className="mt-4 space-y-4">
<h2 className="text-lg font-semibold">EML / MBOX importieren</h2>
<p className="text-sm text-muted-foreground">
Lade .eml oder .mbox Dateien hoch um sie ins Archiv zu importieren. Duplikate werden automatisch übersprungen.
</p>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
onDragLeave={() => setUploadDragging(false)}
onDrop={(e) => {
e.preventDefault();
setUploadDragging(false);
const files = Array.from(e.dataTransfer.files);
onUploadFiles(files);
}}
className={`border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer ${
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"
}`}
onClick={() => document.getElementById("upload-file-input")?.click()}
>
<input
id="upload-file-input"
type="file"
multiple
accept=".eml,.mbox"
className="hidden"
onChange={(e) => {
if (e.target.files) onUploadFiles(Array.from(e.target.files));
e.target.value = "";
}}
/>
<p className="text-sm font-medium">Dateien hierher ziehen oder klicken zum Auswählen</p>
<p className="text-xs text-muted-foreground mt-1">Akzeptiert: .eml, .mbox</p>
</div>
{uploadError && (
<Alert variant="destructive">
<AlertDescription>{uploadError}</AlertDescription>
</Alert>
)}
{/* Progress */}
{(uploadLoading || uploadJob) && uploadJob && (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{uploadJob.status === "running" ? "Import läuft..." : "Import abgeschlossen"}
</span>
<Badge variant={uploadJob.status === "done" ? "default" : "secondary"}>
{uploadJob.status === "done" ? "Fertig" : "Läuft"}
</Badge>
</div>
{/* Progress bar */}
{uploadJob.total > 0 && (
<div className="space-y-1">
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${Math.min(100, ((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} verarbeitet
</p>
</div>
)}
{uploadJob.status === "done" && (
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div className="rounded bg-green-50 dark:bg-green-950 p-2">
<p className="font-bold text-green-700 dark:text-green-400">{uploadJob.imported}</p>
<p className="text-xs text-muted-foreground">Importiert</p>
</div>
<div className="rounded bg-yellow-50 dark:bg-yellow-950 p-2">
<p className="font-bold text-yellow-700 dark:text-yellow-400">{uploadJob.skipped}</p>
<p className="text-xs text-muted-foreground">Übersprungen</p>
</div>
<div className="rounded bg-red-50 dark:bg-red-950 p-2">
<p className="font-bold text-red-700 dark:text-red-400">{uploadJob.errors}</p>
<p className="text-xs text-muted-foreground">Fehler</p>
</div>
</div>
)}
</CardContent>
</Card>
)}
{uploadLoading && !uploadJob && (
<p className="text-sm text-muted-foreground animate-pulse">Upload läuft...</p>
)}
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
"use client";
import { type LDAPConfig, type LDAPTestResult } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface LDAPTabProps {
ldapConfig: LDAPConfig | null;
ldapLoading: boolean;
ldapSaving: boolean;
ldapTesting: boolean;
ldapError: string;
ldapTestResult: LDAPTestResult | null;
ldapForm: LDAPConfig;
setLdapForm: React.Dispatch<React.SetStateAction<LDAPConfig>>;
ldapChangePassword: boolean;
setLdapChangePassword: (v: boolean) => void;
onSave: (e: React.FormEvent) => void;
onTest: () => void;
onDelete: () => void;
}
export function LDAPTab({
ldapConfig,
ldapLoading,
ldapSaving,
ldapTesting,
ldapError,
ldapTestResult,
ldapForm,
setLdapForm,
ldapChangePassword,
setLdapChangePassword,
onSave,
onTest,
onDelete,
}: LDAPTabProps) {
return (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">LDAP / Active Directory</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.enabled}
onChange={(e) => setLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{ldapError && (
<Alert variant="destructive">
<AlertDescription>{ldapError}</AlertDescription>
</Alert>
)}
{ldapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ldap-url">Server-URL</Label>
<Input
id="ldap-url"
placeholder="ldap://server:389 oder ldaps://server:636"
value={ldapForm.url}
onChange={(e) => setLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="ldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={ldapForm.bind_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-pw">Bind-Passwort</Label>
{ldapConfig && !ldapChangePassword ? (
<div className="flex gap-2">
<Input id="ldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="ldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={ldapForm.bind_password}
onChange={(e) => setLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ldap-base-dn">Base-DN</Label>
<Input
id="ldap-base-dn"
placeholder="DC=example,DC=com"
value={ldapForm.base_dn}
onChange={(e) => setLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-filter">User-Filter</Label>
<Input
id="ldap-filter"
placeholder="(sAMAccountName=%s)"
value={ldapForm.user_filter}
onChange={(e) => setLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ldap-role">Standard-Rolle</Label>
<Select
value={ldapForm.default_role}
onValueChange={(v) => setLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="ldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.tls}
onChange={(e) => setLdapForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={ldapForm.tls_skip_verify}
onChange={(e) => setLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{ldapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufügen
</Button>
</div>
{ldapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{ldapForm.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...ldapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = ldapForm.group_mappings.filter((_, j) => j !== i);
setLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{ldapTestResult && (
<Card>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant={ldapTestResult.ok ? "default" : "destructive"}>
{ldapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{ldapTestResult.message}</span>
{ldapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{ldapTestResult.latency_ms} ms</span>
)}
</div>
{ldapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{ldapTestResult.server_info}</p>
)}
{ldapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{ldapTestResult.error_detail}</p>
)}
{ldapTestResult.ok && ldapTestResult.users_found > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">
{ldapTestResult.users_found} Benutzer gefunden
{ldapTestResult.users?.length < ldapTestResult.users_found && (
<span className="text-muted-foreground font-normal"> (Vorschau: {ldapTestResult.users?.length})</span>
)}
</p>
<div className="rounded border overflow-auto max-h-64">
<table className="w-full text-xs">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left px-2 py-1 font-medium">UID</th>
<th className="text-left px-2 py-1 font-medium">Name</th>
<th className="text-left px-2 py-1 font-medium">E-Mail</th>
</tr>
</thead>
<tbody>
{ldapTestResult.users?.map((u, i) => (
<tr key={i} className="border-t">
<td className="px-2 py-1 font-mono">{u.uid || ""}</td>
<td className="px-2 py-1">{u.display_name || ""}</td>
<td className="px-2 py-1 text-muted-foreground">{u.mail || ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={onTest} disabled={ldapTesting || ldapSaving}>
{ldapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={ldapSaving || ldapTesting}>
{ldapSaving ? "Speichern..." : "Speichern"}
</Button>
{ldapConfig && (
<Button
type="button"
variant="destructive"
disabled={ldapSaving}
onClick={onDelete}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{ldapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : ""}
{ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""}
</p>
)}
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import { type MailLabel, type LabelRule } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface LabelsTabProps {
adminLabels: MailLabel[];
adminLabelsLoading: boolean;
adminLabelsError: string;
newLabelName: string;
setNewLabelName: (v: string) => void;
newLabelColor: string;
setNewLabelColor: (v: string) => void;
labelCreating: boolean;
labelRules: LabelRule[];
labelRulesLoading: boolean;
newRuleField: string;
setNewRuleField: (v: string) => void;
newRuleValue: string;
setNewRuleValue: (v: string) => void;
newRuleLabelId: number | null;
setNewRuleLabelId: (v: number | null) => void;
ruleCreating: boolean;
onCreateLabel: (e: React.FormEvent) => void;
onDeleteLabel: (id: number, name: string) => void;
onCreateRule: (e: React.FormEvent) => void;
onDeleteRule: (id: number) => void;
}
export function LabelsTab({
adminLabels,
adminLabelsLoading,
adminLabelsError,
newLabelName,
setNewLabelName,
newLabelColor,
setNewLabelColor,
labelCreating,
labelRules,
labelRulesLoading,
newRuleField,
setNewRuleField,
newRuleValue,
setNewRuleValue,
newRuleLabelId,
setNewRuleLabelId,
ruleCreating,
onCreateLabel,
onDeleteLabel,
onCreateRule,
onDeleteRule,
}: LabelsTabProps) {
return (
<div className="mt-4 space-y-6">
{adminLabelsError && (
<Alert variant="destructive">
<AlertDescription>{adminLabelsError}</AlertDescription>
</Alert>
)}
{/* Globale Labels */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-sm font-semibold">Globale Labels</h3>
<form onSubmit={onCreateLabel} className="flex items-end gap-3">
<div className="space-y-1">
<Label htmlFor="label-name" className="text-xs">Name</Label>
<Input
id="label-name"
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
placeholder="Label-Name"
className="h-8 w-48"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Farbe</Label>
<div className="flex gap-1.5">
{["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${
newLabelColor === c ? "border-foreground scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
onClick={() => setNewLabelColor(c)}
aria-label={`Farbe ${c}`}
/>
))}
</div>
</div>
<Button type="submit" size="sm" disabled={labelCreating || !newLabelName.trim()}>
{labelCreating ? "..." : "Anlegen"}
</Button>
</form>
{adminLabelsLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : adminLabels.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine globalen Labels vorhanden.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-24">Farbe</TableHead>
<TableHead className="w-24">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminLabels.map((label) => (
<TableRow key={label.id}>
<TableCell className="font-medium">{label.name}</TableCell>
<TableCell>
<span
className="inline-block h-4 w-4 rounded-full"
style={{ backgroundColor: label.color }}
aria-label={`Farbe ${label.color}`}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
onClick={() => onDeleteLabel(label.id, label.name)}
>
Loeschen
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Auto-Regeln */}
<Card>
<CardContent className="pt-6 space-y-4">
<h3 className="text-sm font-semibold">Auto-Regeln</h3>
<form onSubmit={onCreateRule} className="flex items-end gap-3 flex-wrap">
<div className="space-y-1">
<Label htmlFor="rule-field" className="text-xs">Bedingung</Label>
<Select value={newRuleField} onValueChange={setNewRuleField}>
<SelectTrigger id="rule-field" className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="from_domain">Absender-Domain</SelectItem>
<SelectItem value="source">Import-Quelle</SelectItem>
<SelectItem value="subject_contains">Betreff enthaelt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="rule-value" className="text-xs">Wert</Label>
<Input
id="rule-value"
value={newRuleValue}
onChange={(e) => setNewRuleValue(e.target.value)}
placeholder="z.B. example.com"
className="h-8 w-48"
/>
</div>
<div className="space-y-1">
<Label htmlFor="rule-label" className="text-xs">Label</Label>
<Select
value={newRuleLabelId !== null ? String(newRuleLabelId) : ""}
onValueChange={(v) => setNewRuleLabelId(Number(v))}
>
<SelectTrigger id="rule-label" className="h-8 w-44 text-xs">
<SelectValue placeholder="Label waehlen..." />
</SelectTrigger>
<SelectContent>
{adminLabels.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
<span className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: l.color }}
/>
{l.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={ruleCreating || !newRuleValue.trim() || !newRuleLabelId}>
{ruleCreating ? "..." : "Regel anlegen"}
</Button>
</form>
{labelRulesLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : labelRules.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Regeln vorhanden.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Bedingung</TableHead>
<TableHead>Wert</TableHead>
<TableHead>Label</TableHead>
<TableHead className="w-24">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{labelRules.map((rule) => {
const condLabels: Record<string, string> = {
from_domain: "Absender-Domain",
source: "Import-Quelle",
subject_contains: "Betreff enthaelt",
};
const matchLabel = adminLabels.find((l) => l.id === rule.label_id);
return (
<TableRow key={rule.id}>
<TableCell className="text-sm">{condLabels[rule.condition_field] || rule.condition_field}</TableCell>
<TableCell className="text-sm font-mono">{rule.condition_value}</TableCell>
<TableCell>
{matchLabel ? (
<span className="flex items-center gap-2 text-sm">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: matchLabel.color }}
/>
{matchLabel.name}
</span>
) : (
<span className="text-xs text-muted-foreground">ID {rule.label_id}</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
onClick={() => onDeleteRule(rule.id)}
>
Loeschen
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { type SecurityCheck, type SecurityAuditResult } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface SecurityTabProps {
securityAudit: SecurityAuditResult | null;
securityLoading: boolean;
securityError: string;
fixLoading: string | null;
fixMessage: string;
onRunAudit: () => void;
onRunFix: (action: string) => void;
}
export function SecurityTab({
securityAudit,
securityLoading,
securityError,
fixLoading,
fixMessage,
onRunAudit,
onRunFix,
}: SecurityTabProps) {
return (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Security Audit</h2>
{securityAudit && (
<p className="text-xs text-muted-foreground mt-0.5">
Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")}
</p>
)}
</div>
<Button onClick={onRunAudit} disabled={securityLoading || fixLoading !== null} size="sm">
{securityLoading ? "Prüfe..." : "Jetzt prüfen"}
</Button>
</div>
{securityError && (
<Alert variant="destructive">
<AlertDescription>{securityError}</AlertDescription>
</Alert>
)}
{fixMessage && (
<Alert>
<AlertDescription className="text-green-700">{fixMessage}</AlertDescription>
</Alert>
)}
{!securityAudit && !securityLoading && !securityError && (
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground">
Klicke auf &ldquo;Jetzt prüfen&rdquo; um den Security-Audit zu starten.
</div>
)}
{securityLoading && (
<div className="space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
)}
{securityAudit && !securityLoading && (() => {
// Map check names to fix actions
const fixActions: Record<string, { action: string; label: string }> = {
"Fail2ban": { action: "install_fail2ban", label: "Installieren & aktivieren" },
"Firewall (nftables)": { action: "enable_firewall", label: "Firewall aktivieren" },
"SSH Passwort-Auth": { action: "fix_ssh_password_auth", label: "Deaktivieren" },
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
};
return (
<div className="space-y-2">
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
return (
<Card key={i}>
<CardContent className="p-4 flex items-start gap-3">
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
check.status === "ok" ? "bg-green-500" :
check.status === "warning" ? "bg-yellow-400" : "bg-red-500"
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{check.name}</span>
<Badge
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
>
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
</div>
{fix && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 text-xs"
disabled={fixLoading !== null}
onClick={() => onRunFix(fix.action)}
>
{fixLoading === fix.action ? "Wird behoben..." : fix.label}
</Button>
)}
{check.name === "HTTPS (TLS)" && check.status !== "ok" && (
<a
href="https://certbot.eff.org/instructions?os=debianbuster&tab=standard"
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm" variant="outline" className="flex-shrink-0 text-xs">
Anleitung
</Button>
</a>
)}
</CardContent>
</Card>
);
})}
{/* Summary */}
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
{[
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
].map((s) => (
<div key={s.label} className={`rounded p-3 ${s.color}`}>
<p className="text-2xl font-bold">{s.count}</p>
<p className="text-xs">{s.label}</p>
</div>
))}
</div>
</div>
);
})()}
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { type ServiceStatus } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface ServicesTabProps {
isSuperAdmin: boolean;
services: ServiceStatus[];
servicesLoading: boolean;
serviceActionLoading: string | null;
serviceError: string;
onLoadServices: () => void;
onServiceAction: (name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") => void;
}
export function ServicesTab({
isSuperAdmin,
services,
servicesLoading,
serviceActionLoading,
serviceError,
onLoadServices,
onServiceAction,
}: ServicesTabProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Systemdienste</h2>
<Button variant="outline" size="sm" onClick={onLoadServices} disabled={servicesLoading}>
{servicesLoading ? "..." : "Aktualisieren"}
</Button>
</div>
{serviceError && (
<Alert variant="destructive">
<AlertDescription>{serviceError}</AlertDescription>
</Alert>
)}
{servicesLoading && services.length === 0 ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Dienst</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-24">Autostart</TableHead>
<TableHead className="w-28">Externer Zugriff</TableHead>
<TableHead>Beschreibung</TableHead>
{isSuperAdmin && <TableHead className="w-72 text-right">Aktionen</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{services.map((svc) => {
const isActive = svc.active === "active";
const isFailed = svc.active === "failed";
const isEnabled = svc.enabled === "enabled" || svc.enabled === "static";
const busy = (key: string) => serviceActionLoading === `${svc.name}:${key}`;
const anyBusy = serviceActionLoading?.startsWith(`${svc.name}:`) ?? false;
return (
<TableRow key={svc.name}>
<TableCell className="font-mono text-sm font-medium">
{svc.name}
</TableCell>
<TableCell>
<Badge
variant={isActive ? "default" : isFailed ? "destructive" : "secondary"}
>
{svc.active === "active"
? `Aktiv (${svc.sub})`
: svc.active === "failed"
? "Fehler"
: svc.active === "inactive"
? "Gestoppt"
: svc.active}
</Badge>
</TableCell>
<TableCell>
<Badge variant={isEnabled ? "default" : "outline"}>
{svc.enabled === "enabled"
? "Aktiviert"
: svc.enabled === "disabled"
? "Deaktiviert"
: svc.enabled === "static"
? "Statisch"
: svc.enabled}
</Badge>
</TableCell>
<TableCell>
{svc.external_blocked !== undefined ? (
<Badge variant={svc.external_blocked ? "destructive" : "default"}>
{svc.external_blocked ? "Gesperrt" : "Offen"}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-xs">
{svc.description || ""}
</TableCell>
{isSuperAdmin && (
<TableCell className="text-right">
<div className="flex justify-end gap-1 flex-wrap">
{isActive ? (
<>
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "restart")}
>
{busy("restart") ? "..." : "Neustart"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "stop")}
>
{busy("stop") ? "..." : "Stop"}
</Button>
</>
) : (
<Button
size="sm"
variant="default"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "start")}
>
{busy("start") ? "..." : "Start"}
</Button>
)}
{svc.enabled === "enabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "disable")}
>
{busy("disable") ? "..." : "Deaktivieren"}
</Button>
) : svc.enabled === "disabled" ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "enable")}
>
{busy("enable") ? "..." : "Aktivieren"}
</Button>
) : null}
{svc.external_blocked !== undefined && (
svc.external_blocked ? (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "allow_external")}
>
{busy("allow_external") ? "..." : "Extern freigeben"}
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={anyBusy}
onClick={() => onServiceAction(svc.name, "block_external")}
>
{busy("block_external") ? "..." : "Extern sperren"}
</Button>
)
)}
</div>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div>
);
}
+393
View File
@@ -0,0 +1,393 @@
"use client";
import { type TenantLDAPConfig, type LDAPTestResult } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TenantLDAPTabProps {
tenantLdapConfig: TenantLDAPConfig | null;
tenantLdapLoading: boolean;
tenantLdapSaving: boolean;
tenantLdapTesting: boolean;
tenantLdapError: string;
tenantLdapTestResult: LDAPTestResult | null;
tenantLdapForm: TenantLDAPConfig;
setTenantLdapForm: React.Dispatch<React.SetStateAction<TenantLDAPConfig>>;
tenantLdapChangePassword: boolean;
setTenantLdapChangePassword: (v: boolean) => void;
ownLogoPreviewUrl: string | null;
ownLogoUploading: boolean;
ownLogoError: string;
onSave: (e: React.FormEvent) => void;
onTest: () => void;
onDelete: () => void;
onOwnLogoUpload: (file: File) => void;
onOwnLogoDelete: () => void;
}
export function TenantLDAPTab({
tenantLdapConfig,
tenantLdapLoading,
tenantLdapSaving,
tenantLdapTesting,
tenantLdapError,
tenantLdapTestResult,
tenantLdapForm,
setTenantLdapForm,
tenantLdapChangePassword,
setTenantLdapChangePassword,
ownLogoPreviewUrl,
ownLogoUploading,
ownLogoError,
onSave,
onTest,
onDelete,
onOwnLogoUpload,
onOwnLogoDelete,
}: TenantLDAPTabProps) {
return (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">LDAP / Active Directory Mandantenkonfiguration</h2>
<p className="text-sm text-muted-foreground mt-1">Konfiguriere den LDAP-Server für deinen Mandanten.</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">LDAP aktiviert</span>
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.enabled}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</div>
</div>
{tenantLdapError && (
<Alert variant="destructive">
<AlertDescription>{tenantLdapError}</AlertDescription>
</Alert>
)}
{tenantLdapLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tldap-url">Server-URL</Label>
<Input
id="tldap-url"
placeholder="ldap://server:389 oder ldaps://server:636"
value={tenantLdapForm.url}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">
Port frei wählbar · Standard: 389 (LDAP), 636 (LDAPS) · Univention UCS: 7389 / 7636
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-bind-dn">Bind-DN (Service-Account)</Label>
<Input
id="tldap-bind-dn"
placeholder="CN=archivmail,OU=Service,DC=example,DC=com"
value={tenantLdapForm.bind_dn}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, bind_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-pw">Bind-Passwort</Label>
{tenantLdapConfig && !tenantLdapChangePassword ? (
<div className="flex gap-2">
<Input id="tldap-pw" type="password" value="••••••" readOnly className="flex-1" />
<Button type="button" variant="outline" size="sm" onClick={() => setTenantLdapChangePassword(true)}>
Ändern
</Button>
</div>
) : (
<Input
id="tldap-pw"
type="password"
placeholder="Neues Passwort eingeben"
value={tenantLdapForm.bind_password}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, bind_password: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tldap-base-dn">Base-DN</Label>
<Input
id="tldap-base-dn"
placeholder="DC=example,DC=com"
value={tenantLdapForm.base_dn}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, base_dn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-filter">User-Filter</Label>
<Input
id="tldap-filter"
placeholder="(sAMAccountName=%s)"
value={tenantLdapForm.user_filter}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, user_filter: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tldap-role">Standard-Rolle</Label>
<Select
value={tenantLdapForm.default_role}
onValueChange={(v) => setTenantLdapForm((f) => ({ ...f, default_role: v }))}
>
<SelectTrigger id="tldap-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.tls}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, tls: e.target.checked }))}
/>
STARTTLS verwenden
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
className="h-4 w-4"
checked={tenantLdapForm.tls_skip_verify}
onChange={(e) => setTenantLdapForm((f) => ({ ...f, tls_skip_verify: e.target.checked }))}
/>
TLS-Zertifikat ignorieren
{tenantLdapForm.tls_skip_verify && (
<Badge variant="destructive" className="text-xs ml-1">Unsicher</Badge>
)}
</label>
</div>
<Separator />
{/* Group mappings -- domain_admin: nur user + auditor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Gruppen-Rollenzuordnung</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setTenantLdapForm((f) => ({
...f,
group_mappings: [...f.group_mappings, { group_dn: "", role: "user" }],
}))
}
>
+ Hinzufuegen
</Button>
</div>
{tenantLdapForm.group_mappings.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Gruppen-Zuordnungen definiert.</p>
) : (
<div className="space-y-2">
{tenantLdapForm.group_mappings.map((gm, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
className="flex-1"
placeholder="CN=Archivadmins,OU=Groups,DC=example,DC=com"
value={gm.group_dn}
onChange={(e) => {
const gms = [...tenantLdapForm.group_mappings];
gms[i] = { ...gms[i], group_dn: e.target.value };
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
/>
<Select
value={gm.role}
onValueChange={(v) => {
const gms = [...tenantLdapForm.group_mappings];
gms[i] = { ...gms[i], role: v };
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
const gms = tenantLdapForm.group_mappings.filter((_, j) => j !== i);
setTenantLdapForm((f) => ({ ...f, group_mappings: gms }));
}}
>
Entfernen
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Test result */}
{tenantLdapTestResult && (
<Card>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant={tenantLdapTestResult.ok ? "default" : "destructive"}>
{tenantLdapTestResult.ok ? "Verbunden" : "Fehler"}
</Badge>
<span className="text-sm">{tenantLdapTestResult.message}</span>
{tenantLdapTestResult.latency_ms > 0 && (
<span className="text-xs text-muted-foreground">{tenantLdapTestResult.latency_ms} ms</span>
)}
</div>
{tenantLdapTestResult.server_info && (
<p className="text-xs text-muted-foreground font-mono">{tenantLdapTestResult.server_info}</p>
)}
{tenantLdapTestResult.error_detail && (
<p className="text-xs text-destructive font-mono">{tenantLdapTestResult.error_detail}</p>
)}
{tenantLdapTestResult.ok && tenantLdapTestResult.users_found > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">
{tenantLdapTestResult.users_found} Benutzer gefunden
{tenantLdapTestResult.users?.length < tenantLdapTestResult.users_found && (
<span className="text-muted-foreground font-normal"> (Vorschau: {tenantLdapTestResult.users?.length})</span>
)}
</p>
<div className="rounded border overflow-auto max-h-64">
<table className="w-full text-xs">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left px-2 py-1 font-medium">UID</th>
<th className="text-left px-2 py-1 font-medium">Name</th>
<th className="text-left px-2 py-1 font-medium">E-Mail</th>
</tr>
</thead>
<tbody>
{tenantLdapTestResult.users?.map((u, i) => (
<tr key={i} className="border-t">
<td className="px-2 py-1 font-mono">{u.uid || ""}</td>
<td className="px-2 py-1">{u.display_name || ""}</td>
<td className="px-2 py-1 text-muted-foreground">{u.mail || ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Action bar */}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={onTest} disabled={tenantLdapTesting || tenantLdapSaving}>
{tenantLdapTesting ? "Teste..." : "Verbindung testen"}
</Button>
<Button type="submit" disabled={tenantLdapSaving || tenantLdapTesting}>
{tenantLdapSaving ? "Speichern..." : "Speichern"}
</Button>
{tenantLdapConfig && (
<Button
type="button"
variant="destructive"
disabled={tenantLdapSaving}
onClick={onDelete}
>
Konfiguration löschen
</Button>
)}
</div>
</form>
)}
{tenantLdapConfig && (
<p className="text-xs text-muted-foreground">
Zuletzt geändert: {tenantLdapConfig.updated_at ? new Date(tenantLdapConfig.updated_at).toLocaleString("de-DE") : ""}
{tenantLdapConfig.updated_by ? ` von ${tenantLdapConfig.updated_by}` : ""}
</p>
)}
{/* Logo section for domain_admin */}
<div className="space-y-3 pt-2">
<div>
<h3 className="text-base font-semibold">Mandanten-Logo</h3>
<p className="text-sm text-muted-foreground mt-1">Logo deines Mandanten hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</p>
</div>
{ownLogoError && <p className="text-sm text-destructive">{ownLogoError}</p>}
{ownLogoPreviewUrl ? (
<div className="flex items-center gap-4">
<div className="flex items-center justify-center rounded border p-3 bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ownLogoPreviewUrl} alt="Logo" className="max-h-20 max-w-40 object-contain" />
</div>
<div className="flex flex-col gap-2">
<Input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
disabled={ownLogoUploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) onOwnLogoUpload(f); }}
className="w-auto"
/>
<Button variant="destructive" size="sm" disabled={ownLogoUploading} onClick={onOwnLogoDelete}>
{ownLogoUploading ? "Bitte warten..." : "Logo entfernen"}
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-4">
<div className="flex items-center justify-center rounded border p-6 bg-muted/30 text-sm text-muted-foreground w-40">
Kein Logo
</div>
<Input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
disabled={ownLogoUploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) onOwnLogoUpload(f); }}
className="w-auto"
/>
</div>
)}
</div>
</div>
);
}
+507
View File
@@ -0,0 +1,507 @@
"use client";
import {
type Tenant,
type TenantDomain,
type TenantDefaultUser,
type User,
type LDAPSyncResult,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { TenantLDAPDialog } from "@/components/admin/TenantLDAPDialog";
interface TenantsTabProps {
tenants: Tenant[];
tenantsLoading: boolean;
tenantsError: string;
// Create dialog
tenantDialogOpen: boolean;
setTenantDialogOpen: (open: boolean) => void;
newTenantName: string;
setNewTenantName: (v: string) => void;
newTenantSlug: string;
setNewTenantSlug: (v: string) => void;
tenantCreateLoading: boolean;
tenantCreateError: string;
onCreateTenant: (e: React.FormEvent) => void;
// Credentials dialog
tenantCredDialogOpen: boolean;
setTenantCredDialogOpen: (open: boolean) => void;
tenantCreatedName: string;
tenantCreatedUsers: TenantDefaultUser[];
// Delete dialog
tenantDeleteId: number | null;
setTenantDeleteId: (id: number | null) => void;
tenantDeleteLoading: boolean;
onDeleteTenant: () => void;
// Domain dialog
domainDialogTenant: Tenant | null;
setDomainDialogTenant: (t: Tenant | null) => void;
tenantDomains: TenantDomain[];
domainsLoading: boolean;
newDomain: string;
setNewDomain: (v: string) => void;
addDomainLoading: boolean;
domainError: string;
onOpenDomainDialog: (t: Tenant) => void;
onAddDomain: () => void;
onRemoveDomain: (domainId: number) => void;
// Users dialog
tenantUsersDialogId: number | null;
setTenantUsersDialogId: (id: number | null) => void;
tenantUsersDialogName: string;
tenantUsersDialogLdap: boolean;
tenantUsers: User[];
tenantUsersLoading: boolean;
tenantUsersError: string;
tenantUsersSyncing: boolean;
tenantUsersSyncResult: LDAPSyncResult | null;
onOpenUsersDialog: (t: Tenant) => void;
onSyncLDAPUsers: () => void;
// Logo dialog
logoDialogTenant: Tenant | null;
logoPreviewUrl: string | null;
logoUploading: boolean;
logoError: string;
onOpenLogoDialog: (t: Tenant) => void;
onLogoUpload: (file: File) => void;
onLogoDelete: () => void;
onLogoDialogClose: () => void;
// Tenant LDAP dialog
tenantLdapDialogId: number | null;
setTenantLdapDialogId: (id: number | null) => void;
onLoadTenants: () => void;
// Toggle / actions
onToggleTenant: (t: Tenant) => void;
}
export function TenantsTab({
tenants,
tenantsLoading,
tenantsError,
tenantDialogOpen,
setTenantDialogOpen,
newTenantName,
setNewTenantName,
newTenantSlug,
setNewTenantSlug,
tenantCreateLoading,
tenantCreateError,
onCreateTenant,
tenantCredDialogOpen,
setTenantCredDialogOpen,
tenantCreatedName,
tenantCreatedUsers,
tenantDeleteId,
setTenantDeleteId,
tenantDeleteLoading,
onDeleteTenant,
domainDialogTenant,
setDomainDialogTenant,
tenantDomains,
domainsLoading,
newDomain,
setNewDomain,
addDomainLoading,
domainError,
onOpenDomainDialog,
onAddDomain,
onRemoveDomain,
tenantUsersDialogId,
setTenantUsersDialogId,
tenantUsersDialogName,
tenantUsersDialogLdap,
tenantUsers,
tenantUsersLoading,
tenantUsersError,
tenantUsersSyncing,
tenantUsersSyncResult,
onOpenUsersDialog,
onSyncLDAPUsers,
logoDialogTenant,
logoPreviewUrl,
logoUploading,
logoError,
onOpenLogoDialog,
onLogoUpload,
onLogoDelete,
onLogoDialogClose,
tenantLdapDialogId,
setTenantLdapDialogId,
onLoadTenants,
onToggleTenant,
}: TenantsTabProps) {
return (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Mandantenverwaltung</h2>
<Dialog open={tenantDialogOpen} onOpenChange={setTenantDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">+ Mandant anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Mandanten anlegen</DialogTitle>
<DialogDescription>Name und URL-Slug für den neuen Mandanten eingeben.</DialogDescription>
</DialogHeader>
<form onSubmit={onCreateTenant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tenant-name">Name</Label>
<Input
id="tenant-name"
value={newTenantName}
required
onChange={(e) => {
setNewTenantName(e.target.value);
setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
value={newTenantSlug}
required
onChange={(e) => setNewTenantSlug(e.target.value)}
/>
</div>
{tenantCreateError && (
<p className="text-sm text-destructive">{tenantCreateError}</p>
)}
<DialogFooter>
<Button type="submit" disabled={tenantCreateLoading}>
{tenantCreateLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{tenantsError && (
<Alert variant="destructive">
<AlertDescription>{tenantsError}</AlertDescription>
</Alert>
)}
{tenantsLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : tenants.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Mandanten vorhanden. Klicke auf &ldquo;+ Mandant anlegen&rdquo; um den ersten Mandanten zu erstellen.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-center">Domains</TableHead>
<TableHead className="text-center">Nutzer</TableHead>
<TableHead>LDAP</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{t.slug}</TableCell>
<TableCell className="text-center">{t.domain_count ?? 0}</TableCell>
<TableCell className="text-center">{t.user_count ?? 0}</TableCell>
<TableCell>
{t.ldap_enabled === true ? (
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Aktiv</Badge>
) : t.ldap_url ? (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">Deaktiviert</Badge>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<Badge variant={t.active ? "default" : "secondary"}>
{t.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => onOpenDomainDialog(t)}>
Domains
</Button>
<Button size="sm" variant="outline" onClick={() => onOpenUsersDialog(t)}>
Nutzer
</Button>
<Button size="sm" variant="outline" onClick={() => setTenantLdapDialogId(t.id)}>
LDAP
</Button>
<Button size="sm" variant="outline" onClick={() => onOpenLogoDialog(t)}>
Logo
</Button>
<Button size="sm" variant="outline" onClick={() => onToggleTenant(t)}>
{t.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setTenantDeleteId(t.id)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
{/* Tenant delete confirmation */}
<Dialog open={tenantDeleteId !== null} onOpenChange={(open) => { if (!open) setTenantDeleteId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mandant löschen</DialogTitle>
<DialogDescription>
Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setTenantDeleteId(null)}>Abbrechen</Button>
<Button variant="destructive" disabled={tenantDeleteLoading} onClick={onDeleteTenant}>
{tenantDeleteLoading ? "Löschen..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Logo upload dialog */}
<Dialog open={logoDialogTenant !== null} onOpenChange={(open) => { if (!open) onLogoDialogClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Logo {logoDialogTenant?.name}</DialogTitle>
<DialogDescription>Logo hochladen (PNG, JPEG, GIF, WebP oder SVG, max. 2 MB).</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{logoPreviewUrl && (
<div className="flex items-center justify-center rounded border p-4 bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoPreviewUrl} alt="Logo" className="max-h-32 max-w-full object-contain" />
</div>
)}
{!logoPreviewUrl && (
<div className="flex items-center justify-center rounded border p-8 bg-muted/30 text-sm text-muted-foreground">
Kein Logo gesetzt
</div>
)}
{logoError && <p className="text-sm text-destructive">{logoError}</p>}
<div className="flex gap-2">
<Input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
disabled={logoUploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) onLogoUpload(f); }}
/>
{logoPreviewUrl && (
<Button variant="destructive" size="sm" disabled={logoUploading} onClick={onLogoDelete}>
Entfernen
</Button>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onLogoDialogClose}>Schließen</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Default credentials dialog after tenant creation */}
<Dialog open={tenantCredDialogOpen} onOpenChange={setTenantCredDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mandant &bdquo;{tenantCreatedName}&ldquo; erstellt</DialogTitle>
<DialogDescription>
Folgende Standard-Benutzer wurden angelegt. Passwörter bitte sofort notieren sie werden nur einmalig angezeigt.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{tenantCreatedUsers.map((u) => (
<div key={u.username} className="rounded border p-3 text-sm font-mono space-y-1">
<div><span className="text-muted-foreground">Benutzer:</span> {u.username}</div>
<div><span className="text-muted-foreground">Passwort:</span> {u.password}</div>
<div><span className="text-muted-foreground">Rolle:</span> {u.role}</div>
</div>
))}
</div>
<DialogFooter>
<Button onClick={() => setTenantCredDialogOpen(false)}>Verstanden</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Domain management dialog */}
<Dialog open={domainDialogTenant !== null} onOpenChange={(open) => { if (!open) setDomainDialogTenant(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Domains: {domainDialogTenant?.name}</DialogTitle>
<DialogDescription>E-Mail-Domains diesem Mandanten zuweisen.</DialogDescription>
</DialogHeader>
{domainError && (
<Alert variant="destructive">
<AlertDescription>{domainError}</AlertDescription>
</Alert>
)}
{domainsLoading ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="space-y-2">
{tenantDomains.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Domains zugewiesen.</p>
) : (
tenantDomains.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded border px-3 py-2">
<span className="font-mono text-sm">{d.domain}</span>
<Button size="sm" variant="destructive" onClick={() => onRemoveDomain(d.id)}>
Entfernen
</Button>
</div>
))
)}
</div>
)}
<div className="flex gap-2 pt-2">
<Input
placeholder="example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onAddDomain(); } }}
/>
<Button onClick={onAddDomain} disabled={addDomainLoading || !newDomain}>
{addDomainLoading ? "..." : "Hinzufügen"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Tenant users dialog */}
<Dialog open={tenantUsersDialogId !== null} onOpenChange={(open) => { if (!open) setTenantUsersDialogId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nutzer: {tenantUsersDialogName}</DialogTitle>
<DialogDescription>Dem Mandanten zugewiesene Benutzerkonten.</DialogDescription>
</DialogHeader>
{tenantUsersLoading ? (
<Skeleton className="h-24 w-full" />
) : tenantUsersError ? (
<Alert variant="destructive">
<AlertDescription>{tenantUsersError}</AlertDescription>
</Alert>
) : tenantUsers.length === 0 ? (
<div className="py-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">Keine lokalen Benutzer diesem Mandanten zugewiesen.</p>
{tenantUsersDialogLdap && (
<p className="text-xs text-muted-foreground">LDAP ist aktiv Benutzer erscheinen hier nach ihrem ersten Login oder nach der Synchronisation.</p>
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Quelle</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenantUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell className="text-sm text-muted-foreground">{u.email}</TableCell>
<TableCell><Badge variant="outline">{u.role}</Badge></TableCell>
<TableCell><Badge variant="secondary">{u.source || "local"}</Badge></TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "secondary"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{tenantUsersDialogLdap && (
<div className="border-t pt-3 space-y-2">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onSyncLDAPUsers}
disabled={tenantUsersSyncing}
>
{tenantUsersSyncing ? "Synchronisiere..." : "LDAP-Benutzer synchronisieren"}
</Button>
{tenantUsersSyncResult && (
<span className="text-sm text-muted-foreground">
{tenantUsersSyncResult.synced} Benutzer synchronisiert
{tenantUsersSyncResult.errors.length > 0 && (
<span className="text-destructive ml-1">({tenantUsersSyncResult.errors.length} Fehler)</span>
)}
</span>
)}
</div>
{tenantUsersSyncResult && (tenantUsersSyncResult.errors?.length ?? 0) > 0 && (
<p className="text-xs text-destructive font-mono">{tenantUsersSyncResult.errors.join(", ")}</p>
)}
</div>
)}
</DialogContent>
</Dialog>
{/* Tenant LDAP dialog (superadmin) */}
{tenantLdapDialogId !== null && (
<TenantLDAPDialog
tenantID={tenantLdapDialogId}
onClose={() => { setTenantLdapDialogId(null); onLoadTenants(); }}
/>
)}
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { type User } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface UsersTabProps {
isSuperAdmin: boolean;
users: User[];
usersLoading: boolean;
usersError: string;
// Create dialog
dialogOpen: boolean;
setDialogOpen: (open: boolean) => void;
newUsername: string;
setNewUsername: (v: string) => void;
newEmail: string;
setNewEmail: (v: string) => void;
newPassword: string;
setNewPassword: (v: string) => void;
newRole: string;
setNewRole: (v: string) => void;
createLoading: boolean;
createError: string;
onCreateUser: (e: React.FormEvent) => void;
// User actions
userActionLoading: number | null;
onToggleActive: (u: User) => void;
onOpenResetPassword: (userId: number) => void;
onOpenDeleteDialog: (u: User) => void;
}
export function UsersTab({
isSuperAdmin,
users,
usersLoading,
usersError,
dialogOpen,
setDialogOpen,
newUsername,
setNewUsername,
newEmail,
setNewEmail,
newPassword,
setNewPassword,
newRole,
setNewRole,
createLoading,
createError,
onCreateUser,
userActionLoading,
onToggleActive,
onOpenResetPassword,
onOpenDeleteDialog,
}: UsersTabProps) {
return (
<div className="mt-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>Benutzer anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
<DialogDescription>
Erstellen Sie einen neuen Benutzer-Account.
</DialogDescription>
</DialogHeader>
<form onSubmit={onCreateUser} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-username">Benutzername</Label>
<Input
id="new-username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
aria-label="Neuer Benutzername"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-email">E-Mail</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
aria-label="Neue E-Mail-Adresse"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Passwort</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-role">Rolle</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger id="new-role" aria-label="Rolle auswaehlen">
<SelectValue placeholder="Rolle waehlen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="domain_admin">Domain Admin</SelectItem>
{isSuperAdmin && <SelectItem value="superadmin">Superadmin</SelectItem>}
</SelectContent>
</Select>
</div>
{createError && (
<p className="text-sm text-destructive" role="alert">
{createError}
</p>
)}
<DialogFooter>
<Button type="submit" disabled={createLoading}>
{createLoading ? "Erstellen..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{usersLoading ? (
<Card>
<CardContent className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
) : usersError ? (
<Card>
<CardContent className="p-8 text-center text-destructive">
{usersError}
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Keine Benutzer vorhanden.
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role}</Badge>
</TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "destructive"}>
{u.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => onOpenResetPassword(u.id)}
>
Passwort
</Button>
<Button
size="sm"
variant="outline"
disabled={userActionLoading === u.id}
onClick={() => onToggleActive(u)}
>
{userActionLoading === u.id ? "..." : u.active ? "Sperren" : "Freischalten"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={userActionLoading === u.id}
onClick={() => onOpenDeleteDialog(u)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
);
}