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:
@@ -0,0 +1,119 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
"github.com/archivmail/internal/storage"
|
||||||
|
"github.com/archivmail/internal/tenantstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetTenantUsage returns current quota config and usage for a tenant.
|
||||||
|
// GET /api/admin/tenant/{id}/quota — superadmin only (PROJ-29).
|
||||||
|
func (s *Server) handleGetTenantUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quota, err := s.tenantStore.GetQuota(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := s.tenantStore.GetUsage(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"quota": quota,
|
||||||
|
"usage": usage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetTenantQuota sets quota limits for a tenant.
|
||||||
|
// PUT /api/admin/tenant/{id}/quota — superadmin only (PROJ-29).
|
||||||
|
func (s *Server) handleSetTenantQuota(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body tenantstore.TenantQuota
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.tenantStore.SetQuota(r.Context(), tenantID, body); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate quota cache so the new limit takes effect immediately
|
||||||
|
storage.InvalidateQuotaCache(tenantID)
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if s.audlog != nil {
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "tenant_quota_changed",
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: fmt.Sprintf("tenant_id=%d", tenantID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAllTenantUsage returns quota and usage for all tenants.
|
||||||
|
// GET /api/admin/quotas — superadmin only (PROJ-29).
|
||||||
|
func (s *Server) handleGetAllTenantUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenants, err := s.tenantStore.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantWithUsage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
MaxStorageBytes *int64 `json:"max_storage_bytes"`
|
||||||
|
MaxUsers *int `json:"max_users"`
|
||||||
|
MaxEmails *int64 `json:"max_emails"`
|
||||||
|
StorageBytes int64 `json:"storage_bytes"`
|
||||||
|
UserCount int64 `json:"user_count"`
|
||||||
|
EmailCount int64 `json:"email_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]tenantWithUsage, 0, len(tenants))
|
||||||
|
for _, t := range tenants {
|
||||||
|
usage, err := s.tenantStore.GetUsage(r.Context(), t.ID)
|
||||||
|
if err != nil {
|
||||||
|
usage = &tenantstore.TenantUsage{}
|
||||||
|
}
|
||||||
|
result = append(result, tenantWithUsage{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
Slug: t.Slug,
|
||||||
|
MaxStorageBytes: t.MaxStorageBytes,
|
||||||
|
MaxUsers: t.MaxUsers,
|
||||||
|
MaxEmails: t.MaxEmails,
|
||||||
|
StorageBytes: usage.StorageBytes,
|
||||||
|
UserCount: usage.UserCount,
|
||||||
|
EmailCount: usage.EmailCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"tenants": result})
|
||||||
|
}
|
||||||
@@ -181,6 +181,11 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
|
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
|
||||||
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
|
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
|
||||||
|
|
||||||
|
// PROJ-29: Quotas — superadmin only
|
||||||
|
s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage)))
|
||||||
|
s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
|
||||||
|
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantQuota)))
|
||||||
|
|
||||||
// PROJ-33: IMAP mode settings — domain_admin only
|
// PROJ-33: IMAP mode settings — domain_admin only
|
||||||
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
||||||
s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode))
|
s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode))
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrQuotaExceeded is returned when a tenant's storage or email quota is exceeded.
|
||||||
|
var ErrQuotaExceeded = errors.New("storage: tenant quota exceeded")
|
||||||
|
|
||||||
|
const quotaCacheTTL = 60 * time.Second
|
||||||
|
|
||||||
|
type quotaEntry struct {
|
||||||
|
exceeded bool
|
||||||
|
expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type quotaCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[int64]quotaEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
var qCache = "aCache{entries: make(map[int64]quotaEntry)}
|
||||||
|
|
||||||
|
// CheckQuota checks whether a tenant has exceeded its storage or email quota.
|
||||||
|
// Returns ErrQuotaExceeded if exceeded. Results are cached for 60 seconds.
|
||||||
|
// If tenantID is nil (no-tenant mail), quota is not enforced.
|
||||||
|
func (s *Store) CheckQuota(ctx context.Context, tenantID *int64) error {
|
||||||
|
if s.db == nil || tenantID == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id := *tenantID
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
qCache.mu.Lock()
|
||||||
|
if e, ok := qCache.entries[id]; ok && time.Now().Before(e.expiry) {
|
||||||
|
exceeded := e.exceeded
|
||||||
|
qCache.mu.Unlock()
|
||||||
|
if exceeded {
|
||||||
|
return ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qCache.mu.Unlock()
|
||||||
|
|
||||||
|
// Query quota and current usage in one statement
|
||||||
|
var maxStorageBytes *int64
|
||||||
|
var maxEmails *int64
|
||||||
|
var currentBytes int64
|
||||||
|
var currentEmails int64
|
||||||
|
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT t.max_storage_bytes, t.max_emails,
|
||||||
|
COALESCE(SUM(e.size), 0) AS total_bytes,
|
||||||
|
COUNT(e.id) AS total_emails
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN emails e ON e.tenant_id = t.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
GROUP BY t.id
|
||||||
|
`, id).Scan(&maxStorageBytes, &maxEmails, ¤tBytes, ¤tEmails)
|
||||||
|
if err != nil {
|
||||||
|
// Tenant not found or DB error — allow the mail to pass through
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exceeded := false
|
||||||
|
if maxStorageBytes != nil && currentBytes >= *maxStorageBytes {
|
||||||
|
exceeded = true
|
||||||
|
}
|
||||||
|
if maxEmails != nil && currentEmails >= *maxEmails {
|
||||||
|
exceeded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to cache
|
||||||
|
qCache.mu.Lock()
|
||||||
|
qCache.entries[id] = quotaEntry{exceeded: exceeded, expiry: time.Now().Add(quotaCacheTTL)}
|
||||||
|
qCache.mu.Unlock()
|
||||||
|
|
||||||
|
if exceeded {
|
||||||
|
return ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateQuotaCache removes the cached quota entry for a tenant.
|
||||||
|
// Call this after quota changes to ensure fresh values on the next check.
|
||||||
|
func InvalidateQuotaCache(tenantID int64) {
|
||||||
|
qCache.mu.Lock()
|
||||||
|
delete(qCache.entries, tenantID)
|
||||||
|
qCache.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -270,7 +270,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: SHA-256-based dedup (fallback / no Message-ID)
|
// Step 3: Quota check (PROJ-29) — reject before writing
|
||||||
|
if err := s.CheckQuota(ctx, tenantID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: SHA-256-based dedup (fallback / no Message-ID)
|
||||||
sum := sha256.Sum256(raw)
|
sum := sha256.Sum256(raw)
|
||||||
id := fmt.Sprintf("%x", sum[:]) // 64 hex chars
|
id := fmt.Sprintf("%x", sum[:]) // 64 hex chars
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package tenantstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantQuota holds the configurable limits for a tenant (nil = unlimited).
|
||||||
|
type TenantQuota struct {
|
||||||
|
MaxStorageBytes *int64 `json:"max_storage_bytes"`
|
||||||
|
MaxUsers *int `json:"max_users"`
|
||||||
|
MaxEmails *int64 `json:"max_emails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantUsage holds the current resource usage for a tenant.
|
||||||
|
type TenantUsage struct {
|
||||||
|
StorageBytes int64 `json:"storage_bytes"`
|
||||||
|
UserCount int64 `json:"user_count"`
|
||||||
|
EmailCount int64 `json:"email_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQuota updates the quota fields for a tenant.
|
||||||
|
// Pass nil to remove a limit (unlimited).
|
||||||
|
func (s *Store) SetQuota(ctx context.Context, tenantID int64, q TenantQuota) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tenants SET max_storage_bytes=$1, max_users=$2, max_emails=$3 WHERE id=$4`,
|
||||||
|
q.MaxStorageBytes, q.MaxUsers, q.MaxEmails, tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tenantstore: set quota: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("tenantstore: tenant %d not found", tenantID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuota returns the quota configuration for a tenant.
|
||||||
|
func (s *Store) GetQuota(ctx context.Context, tenantID int64) (TenantQuota, error) {
|
||||||
|
var q TenantQuota
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT max_storage_bytes, max_users, max_emails FROM tenants WHERE id=$1`, tenantID,
|
||||||
|
).Scan(&q.MaxStorageBytes, &q.MaxUsers, &q.MaxEmails)
|
||||||
|
if err != nil {
|
||||||
|
return q, fmt.Errorf("tenantstore: get quota: %w", err)
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsage returns the current resource usage for a tenant.
|
||||||
|
func (s *Store) GetUsage(ctx context.Context, tenantID int64) (*TenantUsage, error) {
|
||||||
|
u := &TenantUsage{}
|
||||||
|
|
||||||
|
// Email count and storage bytes from emails table
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||||
|
FROM emails
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
`, tenantID).Scan(&u.EmailCount, &u.StorageBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: get usage emails: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User count from users table
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM users WHERE tenant_id = $1`, tenantID,
|
||||||
|
).Scan(&u.UserCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenantstore: get usage users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ type Tenant struct {
|
|||||||
LDAPURL string `json:"ldap_url,omitempty"`
|
LDAPURL string `json:"ldap_url,omitempty"`
|
||||||
HasLogo bool `json:"has_logo,omitempty"`
|
HasLogo bool `json:"has_logo,omitempty"`
|
||||||
RetentionDays int `json:"retention_days"` // 0 = use global config
|
RetentionDays int `json:"retention_days"` // 0 = use global config
|
||||||
|
// Quota fields (PROJ-29) — nil = unlimited
|
||||||
|
MaxStorageBytes *int64 `json:"max_storage_bytes,omitempty"`
|
||||||
|
MaxUsers *int `json:"max_users,omitempty"`
|
||||||
|
MaxEmails *int64 `json:"max_emails,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantDomain is an e-mail domain assigned to a tenant.
|
// TenantDomain is an e-mail domain assigned to a tenant.
|
||||||
@@ -77,6 +81,9 @@ ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA;
|
|||||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT '';
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT '';
|
||||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal';
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal';
|
||||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0;
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_storage_bytes BIGINT;
|
||||||
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_users INT;
|
||||||
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_emails BIGINT;
|
||||||
`
|
`
|
||||||
|
|
||||||
// New connects to PostgreSQL and initialises the tenant schema.
|
// New connects to PostgreSQL and initialises the tenant schema.
|
||||||
@@ -128,7 +135,10 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
|||||||
tl.enabled AS ldap_enabled,
|
tl.enabled AS ldap_enabled,
|
||||||
tl.url AS ldap_url,
|
tl.url AS ldap_url,
|
||||||
(t.logo_data IS NOT NULL) AS has_logo,
|
(t.logo_data IS NOT NULL) AS has_logo,
|
||||||
t.retention_days
|
t.retention_days,
|
||||||
|
t.max_storage_bytes,
|
||||||
|
t.max_users,
|
||||||
|
t.max_emails
|
||||||
FROM tenants t
|
FROM tenants t
|
||||||
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
||||||
LEFT JOIN users u ON u.tenant_id = t.id
|
LEFT JOIN users u ON u.tenant_id = t.id
|
||||||
@@ -144,7 +154,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
|||||||
var tenants []Tenant
|
var tenants []Tenant
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Tenant
|
var t Tenant
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays, &t.MaxStorageBytes, &t.MaxUsers, &t.MaxEmails); err != nil {
|
||||||
return nil, fmt.Errorf("tenantstore: scan: %w", err)
|
return nil, fmt.Errorf("tenantstore: scan: %w", err)
|
||||||
}
|
}
|
||||||
tenants = append(tenants, t)
|
tenants = append(tenants, t)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { CertTab } from "@/components/admin/tabs/CertTab";
|
|||||||
import { ModulesTab } from "@/components/admin/ModulesTab";
|
import { ModulesTab } from "@/components/admin/ModulesTab";
|
||||||
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||||
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
||||||
|
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
||||||
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 25;
|
const AUDIT_PAGE_SIZE = 25;
|
||||||
@@ -809,6 +810,7 @@ export default function AdminPage() {
|
|||||||
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
||||||
|
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -1087,6 +1089,11 @@ export default function AdminPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsContent value="quotas">
|
||||||
|
<QuotaTab />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<TabsContent value="retention">
|
<TabsContent value="retention">
|
||||||
<RetentionTab />
|
<RetentionTab />
|
||||||
|
|||||||
@@ -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 "{edit?.tenant.name}"</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export type {
|
|||||||
CreateTenantResponse,
|
CreateTenantResponse,
|
||||||
TenantLDAPConfig,
|
TenantLDAPConfig,
|
||||||
LDAPSyncResult,
|
LDAPSyncResult,
|
||||||
|
TenantUsageEntry,
|
||||||
} from "./tenants";
|
} from "./tenants";
|
||||||
export {
|
export {
|
||||||
getTenants,
|
getTenants,
|
||||||
@@ -70,6 +71,8 @@ export {
|
|||||||
deleteAdminTenantLDAPConfig,
|
deleteAdminTenantLDAPConfig,
|
||||||
testAdminTenantLDAPConfig,
|
testAdminTenantLDAPConfig,
|
||||||
syncAdminTenantLDAP,
|
syncAdminTenantLDAP,
|
||||||
|
getAllTenantUsage,
|
||||||
|
setTenantQuota,
|
||||||
} from "./tenants";
|
} from "./tenants";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export interface Tenant {
|
|||||||
has_logo?: boolean;
|
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 {
|
export interface TenantDomain {
|
||||||
id: number;
|
id: number;
|
||||||
tenant_id: number;
|
tenant_id: number;
|
||||||
@@ -204,3 +216,20 @@ export async function testAdminTenantLDAPConfig(
|
|||||||
export async function syncAdminTenantLDAP(tenantID: number): Promise<LDAPSyncResult> {
|
export async function syncAdminTenantLDAP(tenantID: number): Promise<LDAPSyncResult> {
|
||||||
return request<LDAPSyncResult>(`/api/admin/tenants/${tenantID}/ldap/sync`, { method: "POST", body: "{}" });
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user