feat(PROJ-29): Tenant-Quotas & Usage-Limits

- DB: max_storage_bytes, max_users, max_emails per Tenant (NULL = unlimited)
- storage/quota.go: CheckQuota() mit 60s-Cache, ErrQuotaExceeded
- Save() prüft Quota vor dem Schreiben — Ablehnung bei Hard-Limit
- tenantstore/quota.go: SetQuota(), GetQuota(), GetUsage()
- API: GET/PUT /api/admin/tenant/{id}/quota, GET /api/admin/quotas
- QuotaTab: Usage-Balken (Speicher/Nutzer/Mails), Edit-Dialog, Warnung ab 80%
- InvalidateQuotaCache() nach Quota-Änderung für sofortige Wirkung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 21:21:11 +02:00
parent ebc9e278ea
commit 7930b85cde
10 changed files with 592 additions and 3 deletions
+7
View File
@@ -77,6 +77,7 @@ import { CertTab } from "@/components/admin/tabs/CertTab";
import { ModulesTab } from "@/components/admin/ModulesTab";
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
const AUDIT_PAGE_SIZE = 25;
@@ -809,6 +810,7 @@ export default function AdminPage() {
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
</TabsList>
@@ -1087,6 +1089,11 @@ export default function AdminPage() {
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="quotas">
<QuotaTab />
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="retention">
<RetentionTab />
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getAllTenantUsage, setTenantQuota, type TenantUsageEntry } from "@/lib/api/tenants";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function usagePercent(used: number, max: number | null): number {
if (!max || max === 0) return 0;
return Math.min(100, Math.round((used / max) * 100));
}
function usageColor(pct: number): string {
if (pct >= 100) return "bg-destructive";
if (pct >= 80) return "bg-yellow-500";
return "bg-primary";
}
function QuotaBadge({ used, max, format }: { used: number; max: number | null; format: (n: number) => string }) {
if (!max) return <Badge variant="secondary">Unbegrenzt</Badge>;
const pct = usagePercent(used, max);
return (
<div className="space-y-1 min-w-[140px]">
<div className="flex justify-between text-xs">
<span>{format(used)}</span>
<span className="text-muted-foreground">/ {format(max)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${usageColor(pct)}`} style={{ width: `${pct}%` }} />
</div>
{pct >= 80 && <span className="text-xs font-medium text-yellow-600">{pct}% belegt</span>}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface EditState {
tenant: TenantUsageEntry;
maxStorageGB: string; // empty = unlimited
maxUsers: string;
maxEmails: string;
}
export function QuotaTab() {
const [tenants, setTenants] = useState<TenantUsageEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [edit, setEdit] = useState<EditState | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
setLoading(true);
getAllTenantUsage()
.then(setTenants)
.catch(() => setError("Quota-Daten konnten nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const handleEditOpen = (t: TenantUsageEntry) => {
setEdit({
tenant: t,
maxStorageGB: t.max_storage_bytes ? String(Math.round(t.max_storage_bytes / (1024 ** 3))) : "",
maxUsers: t.max_users ? String(t.max_users) : "",
maxEmails: t.max_emails ? String(t.max_emails) : "",
});
setSaving(false);
};
const handleEditSave = async () => {
if (!edit) return;
setSaving(true);
try {
const maxStorageBytes = edit.maxStorageGB.trim()
? parseInt(edit.maxStorageGB, 10) * 1024 ** 3
: null;
const maxUsers = edit.maxUsers.trim() ? parseInt(edit.maxUsers, 10) : null;
const maxEmails = edit.maxEmails.trim() ? parseInt(edit.maxEmails, 10) : null;
await setTenantQuota(edit.tenant.id, {
max_storage_bytes: maxStorageBytes,
max_users: maxUsers,
max_emails: maxEmails,
});
setEdit(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler beim Speichern");
} finally {
setSaving(false);
}
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Tenant-Quotas & Verbrauch</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Speicher-, Nutzer- und E-Mail-Limits pro Mandant.
<strong className="text-foreground"> NULL = unbegrenzt.</strong>{" "}
Bei Überschreitung werden neue Mails abgewiesen (bestehende Daten bleiben erhalten).
Soft-Warnung ab 80 %.
</p>
{error && <p className="text-sm text-destructive mb-3">{error}</p>}
{loading ? (
<p className="text-sm text-muted-foreground">Lädt...</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Mandant</TableHead>
<TableHead>Speicher</TableHead>
<TableHead>Nutzer</TableHead>
<TableHead>E-Mails</TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<QuotaBadge
used={t.storage_bytes}
max={t.max_storage_bytes}
format={formatBytes}
/>
</TableCell>
<TableCell>
<QuotaBadge
used={t.user_count}
max={t.max_users}
format={(n) => String(n)}
/>
</TableCell>
<TableCell>
<QuotaBadge
used={t.email_count}
max={t.max_emails}
format={(n) => n.toLocaleString("de-DE")}
/>
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => handleEditOpen(t)}>
Limits
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!edit} onOpenChange={(o) => { if (!o) setEdit(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Quotas für &quot;{edit?.tenant.name}&quot;</DialogTitle>
<DialogDescription>
Leer lassen = unbegrenzt. Speicher in GB angeben.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<label className="text-sm font-medium">Max. Speicher (GB)</label>
<Input
type="number"
min={0}
placeholder="leer = unbegrenzt"
value={edit?.maxStorageGB ?? ""}
onChange={(e) => setEdit((prev) => prev ? { ...prev, maxStorageGB: e.target.value } : prev)}
/>
{edit?.maxStorageGB && (
<p className="text-xs text-muted-foreground">
= {formatBytes(parseInt(edit.maxStorageGB, 10) * 1024 ** 3)}
</p>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Max. Nutzer</label>
<Input
type="number"
min={0}
placeholder="leer = unbegrenzt"
value={edit?.maxUsers ?? ""}
onChange={(e) => setEdit((prev) => prev ? { ...prev, maxUsers: e.target.value } : prev)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Max. E-Mails</label>
<Input
type="number"
min={0}
placeholder="leer = unbegrenzt"
value={edit?.maxEmails ?? ""}
onChange={(e) => setEdit((prev) => prev ? { ...prev, maxEmails: e.target.value } : prev)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEdit(null)}>Abbrechen</Button>
<Button disabled={saving} onClick={handleEditSave}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+3
View File
@@ -46,6 +46,7 @@ export type {
CreateTenantResponse,
TenantLDAPConfig,
LDAPSyncResult,
TenantUsageEntry,
} from "./tenants";
export {
getTenants,
@@ -70,6 +71,8 @@ export {
deleteAdminTenantLDAPConfig,
testAdminTenantLDAPConfig,
syncAdminTenantLDAP,
getAllTenantUsage,
setTenantQuota,
} from "./tenants";
export type {
+29
View File
@@ -17,6 +17,18 @@ export interface Tenant {
has_logo?: boolean;
}
export interface TenantUsageEntry {
id: number;
name: string;
slug: string;
max_storage_bytes: number | null;
max_users: number | null;
max_emails: number | null;
storage_bytes: number;
user_count: number;
email_count: number;
}
export interface TenantDomain {
id: number;
tenant_id: number;
@@ -204,3 +216,20 @@ export async function testAdminTenantLDAPConfig(
export async function syncAdminTenantLDAP(tenantID: number): Promise<LDAPSyncResult> {
return request<LDAPSyncResult>(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" });
}
// ── Tenant Quotas (PROJ-29) ───────────────────────────────────────────────────
export async function getAllTenantUsage(): Promise<TenantUsageEntry[]> {
const data = await request<{ tenants: TenantUsageEntry[] }>("/api/admin/quotas");
return data.tenants;
}
export async function setTenantQuota(
tenantID: number,
quota: { max_storage_bytes: number | null; max_users: number | null; max_emails: number | null }
): Promise<void> {
await request<void>(`/api/admin/tenant/${tenantID}/quota`, {
method: "PUT",
body: JSON.stringify(quota),
});
}