All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1664 lines
58 KiB
TypeScript
1664 lines
58 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Building2,
|
|
Plus,
|
|
Users,
|
|
Shield,
|
|
AlertTriangle,
|
|
Activity,
|
|
GraduationCap,
|
|
Truck,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Pencil,
|
|
Eye,
|
|
RefreshCw,
|
|
Loader2,
|
|
Globe,
|
|
CheckCircle2,
|
|
XCircle,
|
|
BarChart3,
|
|
Search,
|
|
X,
|
|
Settings,
|
|
} from 'lucide-react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface TenantOverview {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
status: string
|
|
max_users: number
|
|
llm_quota_monthly: number
|
|
compliance_score: number
|
|
risk_level: string
|
|
namespace_count: number
|
|
open_incidents: number
|
|
open_reports: number
|
|
pending_dsrs: number
|
|
training_completion_rate: number
|
|
vendor_risk_high: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface TenantNamespace {
|
|
id: string
|
|
tenant_id: string
|
|
name: string
|
|
slug: string
|
|
isolation_level: string
|
|
data_classification: string
|
|
created_at: string
|
|
}
|
|
|
|
interface OverviewResponse {
|
|
tenants: TenantOverview[]
|
|
total: number
|
|
average_score: number
|
|
generated_at: string
|
|
}
|
|
|
|
interface CreateTenantForm {
|
|
name: string
|
|
slug: string
|
|
max_users: number
|
|
llm_quota_monthly: number
|
|
}
|
|
|
|
interface EditTenantForm {
|
|
name: string
|
|
max_users: number
|
|
llm_quota_monthly: number
|
|
status: string
|
|
}
|
|
|
|
interface CreateNamespaceForm {
|
|
name: string
|
|
slug: string
|
|
isolation_level: string
|
|
data_classification: string
|
|
}
|
|
|
|
type SortField = 'name' | 'score' | 'risk'
|
|
type StatusFilter = 'all' | 'active' | 'suspended' | 'inactive'
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
const FALLBACK_TENANT_ID = '00000000-0000-0000-0000-000000000001'
|
|
const FALLBACK_USER_ID = '00000000-0000-0000-0000-000000000001'
|
|
const API_BASE = '/api/sdk/v1/multi-tenant'
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'active', label: 'Aktiv' },
|
|
{ value: 'suspended', label: 'Suspendiert' },
|
|
{ value: 'inactive', label: 'Inaktiv' },
|
|
]
|
|
|
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
|
{ value: 'all', label: 'Alle' },
|
|
{ value: 'active', label: 'Aktiv' },
|
|
{ value: 'suspended', label: 'Suspendiert' },
|
|
{ value: 'inactive', label: 'Inaktiv' },
|
|
]
|
|
|
|
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
|
{ value: 'name', label: 'Name' },
|
|
{ value: 'score', label: 'Score' },
|
|
{ value: 'risk', label: 'Risiko' },
|
|
]
|
|
|
|
const ISOLATION_LEVELS = [
|
|
{ value: 'shared', label: 'Shared' },
|
|
{ value: 'isolated', label: 'Isoliert' },
|
|
{ value: 'dedicated', label: 'Dediziert' },
|
|
]
|
|
|
|
const DATA_CLASSIFICATIONS = [
|
|
{ value: 'public', label: 'Oeffentlich' },
|
|
{ value: 'internal', label: 'Intern' },
|
|
{ value: 'confidential', label: 'Vertraulich' },
|
|
{ value: 'restricted', label: 'Eingeschraenkt' },
|
|
]
|
|
|
|
const RISK_ORDER: Record<string, number> = {
|
|
LOW: 0,
|
|
MEDIUM: 1,
|
|
HIGH: 2,
|
|
CRITICAL: 3,
|
|
}
|
|
|
|
const EMPTY_CREATE_FORM: CreateTenantForm = {
|
|
name: '',
|
|
slug: '',
|
|
max_users: 100,
|
|
llm_quota_monthly: 10000,
|
|
}
|
|
|
|
const EMPTY_NAMESPACE_FORM: CreateNamespaceForm = {
|
|
name: '',
|
|
slug: '',
|
|
isolation_level: 'shared',
|
|
data_classification: 'internal',
|
|
}
|
|
|
|
// =============================================================================
|
|
// HELPERS
|
|
// =============================================================================
|
|
|
|
function getTenantId(): string {
|
|
if (typeof window !== 'undefined') {
|
|
return localStorage.getItem('sdk-tenant-id') || FALLBACK_TENANT_ID
|
|
}
|
|
return FALLBACK_TENANT_ID
|
|
}
|
|
|
|
function getUserId(): string {
|
|
if (typeof window !== 'undefined') {
|
|
return localStorage.getItem('sdk-user-id') || FALLBACK_USER_ID
|
|
}
|
|
return FALLBACK_USER_ID
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function formatDateTime(dateStr: string | null): string {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
function getScoreColor(score: number): string {
|
|
if (score >= 80) return '#22c55e'
|
|
if (score >= 60) return '#eab308'
|
|
if (score >= 40) return '#f97316'
|
|
return '#ef4444'
|
|
}
|
|
|
|
function getScoreBgClasses(score: number): string {
|
|
if (score >= 80) return 'bg-green-50 text-green-700 border-green-200'
|
|
if (score >= 60) return 'bg-amber-50 text-amber-700 border-amber-200'
|
|
if (score >= 40) return 'bg-orange-50 text-orange-700 border-orange-200'
|
|
return 'bg-red-50 text-red-700 border-red-200'
|
|
}
|
|
|
|
function getRiskBadgeClasses(level: string): string {
|
|
switch (level.toUpperCase()) {
|
|
case 'LOW':
|
|
return 'bg-green-100 text-green-800'
|
|
case 'MEDIUM':
|
|
return 'bg-yellow-100 text-yellow-800'
|
|
case 'HIGH':
|
|
return 'bg-orange-100 text-orange-800'
|
|
case 'CRITICAL':
|
|
return 'bg-red-100 text-red-800'
|
|
default:
|
|
return 'bg-gray-100 text-gray-800'
|
|
}
|
|
}
|
|
|
|
function getStatusBadge(status: string): { bg: string; text: string; label: string; icon: React.ReactNode } {
|
|
switch (status) {
|
|
case 'active':
|
|
return {
|
|
bg: 'bg-green-100',
|
|
text: 'text-green-700',
|
|
label: 'Aktiv',
|
|
icon: <CheckCircle2 className="w-3 h-3" />,
|
|
}
|
|
case 'suspended':
|
|
return {
|
|
bg: 'bg-yellow-100',
|
|
text: 'text-yellow-700',
|
|
label: 'Suspendiert',
|
|
icon: <AlertTriangle className="w-3 h-3" />,
|
|
}
|
|
case 'inactive':
|
|
return {
|
|
bg: 'bg-red-100',
|
|
text: 'text-red-700',
|
|
label: 'Inaktiv',
|
|
icon: <XCircle className="w-3 h-3" />,
|
|
}
|
|
default:
|
|
return {
|
|
bg: 'bg-gray-100',
|
|
text: 'text-gray-700',
|
|
label: status,
|
|
icon: null,
|
|
}
|
|
}
|
|
}
|
|
|
|
async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': getTenantId(),
|
|
'X-User-ID': getUserId(),
|
|
...options.headers,
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}))
|
|
throw new Error(body.error || body.message || `HTTP ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
// =============================================================================
|
|
// SUB-COMPONENTS
|
|
// =============================================================================
|
|
|
|
function LoadingSkeleton() {
|
|
return (
|
|
<div className="space-y-8 animate-pulse">
|
|
{/* Header Skeleton */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="h-8 w-80 bg-slate-200 rounded mb-2" />
|
|
<div className="h-4 w-56 bg-slate-200 rounded" />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<div className="h-10 w-10 bg-slate-200 rounded-lg" />
|
|
<div className="h-10 w-52 bg-slate-200 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
{/* Stats Skeleton */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="bg-white rounded-xl border border-slate-200 p-5">
|
|
<div className="h-4 w-28 bg-slate-200 rounded mb-3" />
|
|
<div className="h-8 w-16 bg-slate-200 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Cards Skeleton */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="bg-white rounded-xl border border-slate-200 p-5">
|
|
<div className="h-5 w-40 bg-slate-200 rounded mb-3" />
|
|
<div className="h-4 w-28 bg-slate-200 rounded mb-4" />
|
|
<div className="h-16 w-16 bg-slate-200 rounded-full mx-auto mb-4" />
|
|
<div className="space-y-2">
|
|
<div className="h-3 w-full bg-slate-200 rounded" />
|
|
<div className="h-3 w-3/4 bg-slate-200 rounded" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyState({
|
|
icon,
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
icon: React.ReactNode
|
|
title: string
|
|
description: string
|
|
action?: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="text-center py-16">
|
|
<div className="w-16 h-16 mx-auto bg-indigo-50 rounded-full flex items-center justify-center mb-4">
|
|
{icon}
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
|
<p className="mt-2 text-sm text-slate-500 max-w-md mx-auto">{description}</p>
|
|
{action && <div className="mt-6">{action}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
icon,
|
|
color,
|
|
}: {
|
|
label: string
|
|
value: string | number
|
|
icon: React.ReactNode
|
|
color: 'indigo' | 'green' | 'blue' | 'red'
|
|
}) {
|
|
const colorMap = {
|
|
indigo: {
|
|
iconBg: 'bg-indigo-100',
|
|
iconText: 'text-indigo-600',
|
|
valueBg: '',
|
|
},
|
|
green: {
|
|
iconBg: 'bg-green-100',
|
|
iconText: 'text-green-600',
|
|
valueBg: '',
|
|
},
|
|
blue: {
|
|
iconBg: 'bg-blue-100',
|
|
iconText: 'text-blue-600',
|
|
valueBg: '',
|
|
},
|
|
red: {
|
|
iconBg: 'bg-red-100',
|
|
iconText: 'text-red-600',
|
|
valueBg: '',
|
|
},
|
|
}
|
|
const c = colorMap[color]
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-sm transition-shadow">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className={`w-10 h-10 rounded-lg ${c.iconBg} flex items-center justify-center`}>
|
|
<span className={c.iconText}>{icon}</span>
|
|
</div>
|
|
<span className="text-sm font-medium text-slate-500">{label}</span>
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900">{value}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ComplianceRing({ score, size = 64 }: { score: number; size?: number }) {
|
|
const strokeWidth = 5
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = 2 * Math.PI * radius
|
|
const progress = Math.max(0, Math.min(100, score))
|
|
const offset = circumference - (progress / 100) * circumference
|
|
const color = getScoreColor(score)
|
|
|
|
return (
|
|
<div className="relative" style={{ width: size, height: size }}>
|
|
<svg width={size} height={size} className="transform -rotate-90">
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#e2e8f0"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
className="transition-all duration-700 ease-out"
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<span className="text-sm font-bold text-slate-900">{score}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Modal({
|
|
open,
|
|
onClose,
|
|
title,
|
|
children,
|
|
maxWidth = 'max-w-lg',
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
title: string
|
|
children: React.ReactNode
|
|
maxWidth?: string
|
|
}) {
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
|
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${maxWidth} max-h-[90vh] overflow-y-auto`}>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="px-6 py-5">{children}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TENANT CARD
|
|
// =============================================================================
|
|
|
|
function TenantCard({
|
|
tenant,
|
|
onEdit,
|
|
onViewDetails,
|
|
onSwitchTenant,
|
|
}: {
|
|
tenant: TenantOverview
|
|
onEdit: (t: TenantOverview) => void
|
|
onViewDetails: (t: TenantOverview) => void
|
|
onSwitchTenant: (t: TenantOverview) => void
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const statusInfo = getStatusBadge(tenant.status)
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 hover:border-indigo-300 hover:shadow-md transition-all overflow-hidden">
|
|
{/* Card Header */}
|
|
<div className="p-5">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="text-base font-semibold text-slate-900 truncate">{tenant.name}</h3>
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.text}`}>
|
|
{statusInfo.icon}
|
|
{statusInfo.label}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-slate-400 font-mono">{tenant.slug}</p>
|
|
</div>
|
|
<ComplianceRing score={tenant.compliance_score} size={56} />
|
|
</div>
|
|
|
|
{/* Risk Level */}
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-full ${getRiskBadgeClasses(tenant.risk_level)}`}>
|
|
<Shield className="w-3 h-3" />
|
|
{tenant.risk_level}
|
|
</span>
|
|
<span className="text-xs text-slate-400">
|
|
{tenant.namespace_count} Namespace{tenant.namespace_count !== 1 ? 's' : ''}
|
|
</span>
|
|
<span className="text-xs text-slate-400 ml-auto">
|
|
{formatDate(tenant.created_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Quick Metrics */}
|
|
<div className="grid grid-cols-2 gap-2 mb-4">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5 text-orange-500" />
|
|
<span>{tenant.open_incidents} Vorfaelle</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
|
|
<Shield className="w-3.5 h-3.5 text-indigo-500" />
|
|
<span>{tenant.open_reports} Meldungen</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
|
|
<Users className="w-3.5 h-3.5 text-blue-500" />
|
|
<span>{tenant.pending_dsrs} DSRs</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
|
|
<GraduationCap className="w-3.5 h-3.5 text-green-500" />
|
|
<span>{tenant.training_completion_rate}% Training</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Vendor Risk Info */}
|
|
{tenant.vendor_risk_high > 0 && (
|
|
<div className="flex items-center gap-1.5 text-xs text-orange-600 bg-orange-50 rounded-lg px-2.5 py-1.5 mb-4">
|
|
<Truck className="w-3.5 h-3.5" />
|
|
<span>{tenant.vendor_risk_high} Dienstleister mit hohem Risiko</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => onViewDetails(tenant)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
Details
|
|
</button>
|
|
<button
|
|
onClick={() => onEdit(tenant)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-600 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
onClick={() => onSwitchTenant(tenant)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors ml-auto"
|
|
>
|
|
<Globe className="w-3.5 h-3.5" />
|
|
Wechseln
|
|
</button>
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Section */}
|
|
{expanded && (
|
|
<div className="border-t border-slate-100 bg-slate-50/50 px-5 py-4 space-y-3">
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
<div>
|
|
<span className="text-slate-400">Max. Benutzer</span>
|
|
<p className="font-semibold text-slate-700">{tenant.max_users.toLocaleString('de-DE')}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400">LLM Kontingent / Monat</span>
|
|
<p className="font-semibold text-slate-700">{tenant.llm_quota_monthly.toLocaleString('de-DE')}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400">Compliance Score</span>
|
|
<p className="font-semibold text-slate-700">{tenant.compliance_score} / 100</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400">Aktualisiert</span>
|
|
<p className="font-semibold text-slate-700">{formatDateTime(tenant.updated_at)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CREATE TENANT MODAL
|
|
// =============================================================================
|
|
|
|
function CreateTenantModal({
|
|
open,
|
|
onClose,
|
|
onCreated,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
onCreated: () => void
|
|
}) {
|
|
const [form, setForm] = useState<CreateTenantForm>({ ...EMPTY_CREATE_FORM })
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [slugManual, setSlugManual] = useState(false)
|
|
|
|
const handleNameChange = (name: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
name,
|
|
slug: slugManual ? prev.slug : slugify(name),
|
|
}))
|
|
}
|
|
|
|
const handleSlugChange = (slug: string) => {
|
|
setSlugManual(true)
|
|
setForm((prev) => ({ ...prev, slug: slugify(slug) }))
|
|
}
|
|
|
|
const isValid = form.name.trim().length >= 2 && form.slug.trim().length >= 2
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!isValid) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
await apiFetch('/tenants', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: form.name.trim(),
|
|
slug: form.slug.trim(),
|
|
max_users: form.max_users,
|
|
llm_quota_monthly: form.llm_quota_monthly,
|
|
}),
|
|
})
|
|
setForm({ ...EMPTY_CREATE_FORM })
|
|
setSlugManual(false)
|
|
onCreated()
|
|
onClose()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
setForm({ ...EMPTY_CREATE_FORM })
|
|
setSlugManual(false)
|
|
setError(null)
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={handleClose} title="Neuen Mandanten erstellen">
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
placeholder="z.B. Musterfirma GmbH"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Slug <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.slug}
|
|
onChange={(e) => handleSlugChange(e.target.value)}
|
|
placeholder="musterfirma-gmbh"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
<p className="mt-1 text-xs text-slate-400">
|
|
Kleinbuchstaben, keine Leerzeichen. Wird automatisch aus dem Namen generiert.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Max. Benutzer
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={form.max_users}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
|
|
min={1}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
LLM Kontingent / Monat
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={form.llm_quota_monthly}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, llm_quota_monthly: parseInt(e.target.value) || 0 }))}
|
|
min={0}
|
|
step={1000}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!isValid || saving}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Mandant erstellen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// EDIT TENANT MODAL
|
|
// =============================================================================
|
|
|
|
function EditTenantModal({
|
|
open,
|
|
onClose,
|
|
tenant,
|
|
onUpdated,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
tenant: TenantOverview | null
|
|
onUpdated: () => void
|
|
}) {
|
|
const [form, setForm] = useState<EditTenantForm>({
|
|
name: '',
|
|
max_users: 100,
|
|
llm_quota_monthly: 10000,
|
|
status: 'active',
|
|
})
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (tenant) {
|
|
setForm({
|
|
name: tenant.name,
|
|
max_users: tenant.max_users,
|
|
llm_quota_monthly: tenant.llm_quota_monthly,
|
|
status: tenant.status,
|
|
})
|
|
setError(null)
|
|
}
|
|
}, [tenant])
|
|
|
|
const isValid = form.name.trim().length >= 2
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!isValid || !tenant) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
await apiFetch(`/tenants/${tenant.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
name: form.name.trim(),
|
|
max_users: form.max_users,
|
|
llm_quota_monthly: form.llm_quota_monthly,
|
|
status: form.status,
|
|
}),
|
|
})
|
|
onUpdated()
|
|
onClose()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Aktualisieren')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={`Mandant bearbeiten: ${tenant?.name || ''}`}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Status
|
|
</label>
|
|
<select
|
|
value={form.status}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
|
>
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Max. Benutzer
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={form.max_users}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
|
|
min={1}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
LLM Kontingent / Monat
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={form.llm_quota_monthly}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, llm_quota_monthly: parseInt(e.target.value) || 0 }))}
|
|
min={0}
|
|
step={1000}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!isValid || saving}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Speichern
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TENANT DETAIL MODAL
|
|
// =============================================================================
|
|
|
|
function TenantDetailModal({
|
|
open,
|
|
onClose,
|
|
tenant,
|
|
onSwitchTenant,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
tenant: TenantOverview | null
|
|
onSwitchTenant: (t: TenantOverview) => void
|
|
}) {
|
|
const [namespaces, setNamespaces] = useState<TenantNamespace[]>([])
|
|
const [namespacesLoading, setNamespacesLoading] = useState(false)
|
|
const [namespacesError, setNamespacesError] = useState<string | null>(null)
|
|
const [showCreateNs, setShowCreateNs] = useState(false)
|
|
const [nsForm, setNsForm] = useState<CreateNamespaceForm>({ ...EMPTY_NAMESPACE_FORM })
|
|
const [nsCreating, setNsCreating] = useState(false)
|
|
const [nsError, setNsError] = useState<string | null>(null)
|
|
|
|
const loadNamespaces = useCallback(async () => {
|
|
if (!tenant) return
|
|
setNamespacesLoading(true)
|
|
setNamespacesError(null)
|
|
try {
|
|
const data = await apiFetch<{ namespaces: TenantNamespace[]; total: number }>(
|
|
`/tenants/${tenant.id}/namespaces`
|
|
)
|
|
setNamespaces(data.namespaces || [])
|
|
} catch (err) {
|
|
setNamespacesError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
|
} finally {
|
|
setNamespacesLoading(false)
|
|
}
|
|
}, [tenant])
|
|
|
|
useEffect(() => {
|
|
if (open && tenant) {
|
|
loadNamespaces()
|
|
setShowCreateNs(false)
|
|
setNsForm({ ...EMPTY_NAMESPACE_FORM })
|
|
setNsError(null)
|
|
}
|
|
}, [open, tenant, loadNamespaces])
|
|
|
|
const handleNsNameChange = (name: string) => {
|
|
setNsForm((prev) => ({
|
|
...prev,
|
|
name,
|
|
slug: slugify(name),
|
|
}))
|
|
}
|
|
|
|
const handleCreateNamespace = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!tenant || nsForm.name.trim().length < 2) return
|
|
setNsCreating(true)
|
|
setNsError(null)
|
|
try {
|
|
await apiFetch(`/tenants/${tenant.id}/namespaces`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: nsForm.name.trim(),
|
|
slug: nsForm.slug.trim(),
|
|
isolation_level: nsForm.isolation_level,
|
|
data_classification: nsForm.data_classification,
|
|
}),
|
|
})
|
|
setNsForm({ ...EMPTY_NAMESPACE_FORM })
|
|
setShowCreateNs(false)
|
|
loadNamespaces()
|
|
} catch (err) {
|
|
setNsError(err instanceof Error ? err.message : 'Fehler beim Erstellen')
|
|
} finally {
|
|
setNsCreating(false)
|
|
}
|
|
}
|
|
|
|
if (!tenant) return null
|
|
|
|
const statusInfo = getStatusBadge(tenant.status)
|
|
const scoreColor = getScoreColor(tenant.compliance_score)
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={`Mandant: ${tenant.name}`} maxWidth="max-w-2xl">
|
|
<div className="space-y-6">
|
|
{/* Overview Section */}
|
|
<div className="flex items-start gap-5">
|
|
<ComplianceRing score={tenant.compliance_score} size={80} />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<h3 className="text-lg font-semibold text-slate-900">{tenant.name}</h3>
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.text}`}>
|
|
{statusInfo.icon}
|
|
{statusInfo.label}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-slate-400 font-mono mb-2">{tenant.slug}</p>
|
|
<div className="flex items-center gap-3 text-xs text-slate-500">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 font-semibold rounded-full ${getRiskBadgeClasses(tenant.risk_level)}`}>
|
|
{tenant.risk_level}
|
|
</span>
|
|
<span>Erstellt: {formatDate(tenant.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Compliance Breakdown */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4 text-indigo-500" />
|
|
Compliance-Uebersicht
|
|
</h4>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
<AlertTriangle className="w-3.5 h-3.5 text-orange-500" />
|
|
Offene Vorfaelle
|
|
</div>
|
|
<p className="text-lg font-bold text-slate-900">{tenant.open_incidents}</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
<Shield className="w-3.5 h-3.5 text-indigo-500" />
|
|
Hinweisgebermeldungen
|
|
</div>
|
|
<p className="text-lg font-bold text-slate-900">{tenant.open_reports}</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
<Users className="w-3.5 h-3.5 text-blue-500" />
|
|
Ausstehende DSRs
|
|
</div>
|
|
<p className="text-lg font-bold text-slate-900">{tenant.pending_dsrs}</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
<GraduationCap className="w-3.5 h-3.5 text-green-500" />
|
|
Schulungsquote
|
|
</div>
|
|
<p className="text-lg font-bold text-slate-900">{tenant.training_completion_rate}%</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
<Truck className="w-3.5 h-3.5 text-orange-500" />
|
|
Hochrisiko-Dienstleister
|
|
</div>
|
|
<p className="text-lg font-bold text-slate-900">{tenant.vendor_risk_high}</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
|
|
<Activity className="w-3.5 h-3.5" style={{ color: scoreColor }} />
|
|
Compliance-Score
|
|
</div>
|
|
<p className="text-lg font-bold" style={{ color: scoreColor }}>
|
|
{tenant.compliance_score}/100
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Settings */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
|
<Settings className="w-4 h-4 text-indigo-500" />
|
|
Mandanten-Einstellungen
|
|
</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<span className="text-xs text-slate-400">Max. Benutzer</span>
|
|
<p className="text-base font-semibold text-slate-800">{tenant.max_users.toLocaleString('de-DE')}</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3">
|
|
<span className="text-xs text-slate-400">LLM Kontingent / Monat</span>
|
|
<p className="text-base font-semibold text-slate-800">{tenant.llm_quota_monthly.toLocaleString('de-DE')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Namespaces */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-sm font-semibold text-slate-700 flex items-center gap-2">
|
|
<Globe className="w-4 h-4 text-indigo-500" />
|
|
Namespaces ({namespaces.length})
|
|
</h4>
|
|
<button
|
|
onClick={() => setShowCreateNs(!showCreateNs)}
|
|
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
Neuer Namespace
|
|
</button>
|
|
</div>
|
|
|
|
{/* Create Namespace Form */}
|
|
{showCreateNs && (
|
|
<form onSubmit={handleCreateNamespace} className="mb-4 p-4 bg-indigo-50/50 rounded-lg border border-indigo-100 space-y-3">
|
|
{nsError && (
|
|
<div className="flex items-center gap-2 p-2 text-xs text-red-700 bg-red-50 rounded-lg border border-red-200">
|
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
|
{nsError}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
value={nsForm.name}
|
|
onChange={(e) => handleNsNameChange(e.target.value)}
|
|
placeholder="z.B. Produktion"
|
|
className="w-full px-2.5 py-1.5 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">Slug</label>
|
|
<input
|
|
type="text"
|
|
value={nsForm.slug}
|
|
readOnly
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs bg-slate-50 font-mono"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">Isolationsebene</label>
|
|
<select
|
|
value={nsForm.isolation_level}
|
|
onChange={(e) => setNsForm((prev) => ({ ...prev, isolation_level: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
|
>
|
|
{ISOLATION_LEVELS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">Datenklassifikation</label>
|
|
<select
|
|
value={nsForm.data_classification}
|
|
onChange={(e) => setNsForm((prev) => ({ ...prev, data_classification: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
|
>
|
|
{DATA_CLASSIFICATIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setShowCreateNs(false); setNsError(null) }}
|
|
className="px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={nsForm.name.trim().length < 2 || nsCreating}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
>
|
|
{nsCreating && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
Erstellen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Namespaces List */}
|
|
{namespacesLoading ? (
|
|
<div className="space-y-2 animate-pulse">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="h-12 bg-slate-100 rounded-lg" />
|
|
))}
|
|
</div>
|
|
) : namespacesError ? (
|
|
<div className="flex items-center gap-2 p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
{namespacesError}
|
|
</div>
|
|
) : namespaces.length === 0 ? (
|
|
<div className="text-center py-6 text-sm text-slate-400">
|
|
Noch keine Namespaces vorhanden.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{namespaces.map((ns) => (
|
|
<div
|
|
key={ns.id}
|
|
className="flex items-center gap-3 px-3 py-2.5 bg-white rounded-lg border border-slate-200 text-sm"
|
|
>
|
|
<Globe className="w-4 h-4 text-indigo-400 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-slate-800 truncate">{ns.name}</p>
|
|
<p className="text-xs text-slate-400 font-mono">{ns.slug}</p>
|
|
</div>
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-slate-100 text-slate-600 rounded-full">
|
|
{ns.isolation_level}
|
|
</span>
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-blue-50 text-blue-600 rounded-full">
|
|
{ns.data_classification}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Switch Tenant Action */}
|
|
<div className="pt-4 border-t border-slate-200">
|
|
<button
|
|
onClick={() => {
|
|
onSwitchTenant(tenant)
|
|
onClose()
|
|
}}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
|
>
|
|
<Globe className="w-4 h-4" />
|
|
Zu diesem Mandanten wechseln
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE COMPONENT
|
|
// =============================================================================
|
|
|
|
export default function MultiTenantPage() {
|
|
// Data state
|
|
const [tenants, setTenants] = useState<TenantOverview[]>([])
|
|
const [totalTenants, setTotalTenants] = useState(0)
|
|
const [averageScore, setAverageScore] = useState(0)
|
|
const [generatedAt, setGeneratedAt] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// UI state
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
|
const [sortField, setSortField] = useState<SortField>('name')
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
// Modal state
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [editTenant, setEditTenant] = useState<TenantOverview | null>(null)
|
|
const [detailTenant, setDetailTenant] = useState<TenantOverview | null>(null)
|
|
|
|
// Switch tenant notification
|
|
const [switchNotification, setSwitchNotification] = useState<string | null>(null)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DATA LOADING
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const loadOverview = useCallback(async (showRefresh = false) => {
|
|
if (showRefresh) setRefreshing(true)
|
|
else setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const data = await apiFetch<OverviewResponse>('/overview')
|
|
setTenants(data.tenants || [])
|
|
setTotalTenants(data.total || 0)
|
|
setAverageScore(data.average_score || 0)
|
|
setGeneratedAt(data.generated_at || null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Mandanten')
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadOverview()
|
|
}, [loadOverview])
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FILTERING, SEARCHING, SORTING
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const filteredTenants = React.useMemo(() => {
|
|
let result = [...tenants]
|
|
|
|
// Filter by status
|
|
if (statusFilter !== 'all') {
|
|
result = result.filter((t) => t.status === statusFilter)
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase().trim()
|
|
result = result.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(q) ||
|
|
t.slug.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
|
|
// Sort
|
|
result.sort((a, b) => {
|
|
switch (sortField) {
|
|
case 'name':
|
|
return a.name.localeCompare(b.name, 'de-DE')
|
|
case 'score':
|
|
return b.compliance_score - a.compliance_score
|
|
case 'risk':
|
|
return (RISK_ORDER[b.risk_level] || 0) - (RISK_ORDER[a.risk_level] || 0)
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
|
|
return result
|
|
}, [tenants, statusFilter, searchQuery, sortField])
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DERIVED STATS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const activeTenants = React.useMemo(
|
|
() => tenants.filter((t) => t.status === 'active').length,
|
|
[tenants]
|
|
)
|
|
|
|
const criticalRisks = React.useMemo(
|
|
() => tenants.filter((t) => t.risk_level === 'HIGH' || t.risk_level === 'CRITICAL').length,
|
|
[tenants]
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HANDLERS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const handleRefresh = () => loadOverview(true)
|
|
|
|
const handleSwitchTenant = async (tenant: TenantOverview) => {
|
|
try {
|
|
await apiFetch<{ tenant: { id: string; name: string } }>('/switch', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ tenant_id: tenant.id }),
|
|
})
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('sdk-tenant-id', tenant.id)
|
|
}
|
|
setSwitchNotification(`Mandant gewechselt zu: ${tenant.name}`)
|
|
setTimeout(() => setSwitchNotification(null), 4000)
|
|
} catch (err) {
|
|
// Fallback: just set in localStorage even if API call fails
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('sdk-tenant-id', tenant.id)
|
|
}
|
|
setSwitchNotification(`Mandant gewechselt zu: ${tenant.name}`)
|
|
setTimeout(() => setSwitchNotification(null), 4000)
|
|
}
|
|
}
|
|
|
|
const handleCreated = () => {
|
|
loadOverview(true)
|
|
}
|
|
|
|
const handleUpdated = () => {
|
|
loadOverview(true)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RENDER: LOADING STATE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
<LoadingSkeleton />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RENDER: ERROR STATE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (error && tenants.length === 0) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
<div className="text-center py-20">
|
|
<div className="w-16 h-16 mx-auto bg-red-50 rounded-full flex items-center justify-center mb-4">
|
|
<AlertTriangle className="w-8 h-8 text-red-500" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-slate-900 mb-2">Fehler beim Laden</h2>
|
|
<p className="text-sm text-slate-500 mb-6 max-w-md mx-auto">{error}</p>
|
|
<button
|
|
onClick={() => loadOverview()}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RENDER: MAIN PAGE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
{/* Switch Notification */}
|
|
{switchNotification && (
|
|
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-3 bg-indigo-600 text-white text-sm font-medium rounded-lg shadow-lg animate-in slide-in-from-top-2">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
{switchNotification}
|
|
</div>
|
|
)}
|
|
|
|
{/* ================================================================= */}
|
|
{/* HEADER */}
|
|
{/* ================================================================= */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-indigo-100 rounded-xl flex items-center justify-center">
|
|
<Building2 className="w-5 h-5 text-indigo-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Multi-Tenant Verwaltung</h1>
|
|
<p className="text-sm text-slate-500">
|
|
Mandanten verwalten und Compliance-Status ueberwachen
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="p-2.5 text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
|
|
title="Aktualisieren"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors shadow-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Neuen Mandanten erstellen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Generated At */}
|
|
{generatedAt && (
|
|
<p className="text-xs text-slate-400 mb-6 -mt-4">
|
|
Zuletzt aktualisiert: {formatDateTime(generatedAt)}
|
|
</p>
|
|
)}
|
|
|
|
{/* ================================================================= */}
|
|
{/* STATS OVERVIEW */}
|
|
{/* ================================================================= */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
<StatCard
|
|
label="Gesamt Mandanten"
|
|
value={totalTenants}
|
|
icon={<Building2 className="w-5 h-5" />}
|
|
color="indigo"
|
|
/>
|
|
<StatCard
|
|
label="Aktive Mandanten"
|
|
value={activeTenants}
|
|
icon={<CheckCircle2 className="w-5 h-5" />}
|
|
color="green"
|
|
/>
|
|
<StatCard
|
|
label="Durchschn. Compliance-Score"
|
|
value={`${Math.round(averageScore)}%`}
|
|
icon={<BarChart3 className="w-5 h-5" />}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
label="Kritische Risiken"
|
|
value={criticalRisks}
|
|
icon={<AlertTriangle className="w-5 h-5" />}
|
|
color="red"
|
|
/>
|
|
</div>
|
|
|
|
{/* ================================================================= */}
|
|
{/* SEARCH & FILTER BAR */}
|
|
{/* ================================================================= */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
{/* Search Input */}
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Mandant suchen (Name oder Slug)..."
|
|
className="w-full pl-9 pr-9 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-slate-400 hover:text-slate-600"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-1">
|
|
{FILTER_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setStatusFilter(opt.value)}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
statusFilter === opt.value
|
|
? 'bg-white text-indigo-600 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sort */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-400 whitespace-nowrap">Sortierung:</span>
|
|
<select
|
|
value={sortField}
|
|
onChange={(e) => setSortField(e.target.value as SortField)}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
|
>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ================================================================= */}
|
|
{/* REFRESH ERROR BANNER */}
|
|
{/* ================================================================= */}
|
|
{error && tenants.length > 0 && (
|
|
<div className="flex items-center gap-2 p-3 mb-6 text-sm text-amber-700 bg-amber-50 rounded-lg border border-amber-200">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
<span>Fehler beim Aktualisieren: {error}</span>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="ml-auto text-xs font-medium text-amber-700 hover:text-amber-800 underline"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ================================================================= */}
|
|
{/* TENANT CARDS GRID */}
|
|
{/* ================================================================= */}
|
|
{filteredTenants.length === 0 ? (
|
|
tenants.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Building2 className="w-8 h-8 text-indigo-400" />}
|
|
title="Keine Mandanten vorhanden"
|
|
description="Erstellen Sie Ihren ersten Mandanten, um die Multi-Tenant-Verwaltung zu nutzen."
|
|
action={
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Neuen Mandanten erstellen
|
|
</button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
icon={<Search className="w-8 h-8 text-slate-400" />}
|
|
title="Keine Ergebnisse"
|
|
description={`Kein Mandant gefunden fuer "${searchQuery}"${statusFilter !== 'all' ? ` mit Status "${FILTER_OPTIONS.find((f) => f.value === statusFilter)?.label}"` : ''}.`}
|
|
action={
|
|
<button
|
|
onClick={() => {
|
|
setSearchQuery('')
|
|
setStatusFilter('all')
|
|
}}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
|
|
>
|
|
Filter zuruecksetzen
|
|
</button>
|
|
}
|
|
/>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm text-slate-500">
|
|
{filteredTenants.length} von {tenants.length} Mandant{tenants.length !== 1 ? 'en' : ''}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{filteredTenants.map((tenant) => (
|
|
<TenantCard
|
|
key={tenant.id}
|
|
tenant={tenant}
|
|
onEdit={setEditTenant}
|
|
onViewDetails={setDetailTenant}
|
|
onSwitchTenant={handleSwitchTenant}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ================================================================= */}
|
|
{/* MODALS */}
|
|
{/* ================================================================= */}
|
|
<CreateTenantModal
|
|
open={showCreate}
|
|
onClose={() => setShowCreate(false)}
|
|
onCreated={handleCreated}
|
|
/>
|
|
|
|
<EditTenantModal
|
|
open={editTenant !== null}
|
|
onClose={() => setEditTenant(null)}
|
|
tenant={editTenant}
|
|
onUpdated={handleUpdated}
|
|
/>
|
|
|
|
<TenantDetailModal
|
|
open={detailTenant !== null}
|
|
onClose={() => setDetailTenant(null)}
|
|
tenant={detailTenant}
|
|
onSwitchTenant={handleSwitchTenant}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|