Files
breakpilot-lehrer/admin-lehrer/app/(admin)/rbac/page.tsx
Benjamin Admin b9c3c47a37 refactor: LLM Compare komplett entfernt, Video/Voice/Alerts Sidebar hinzugefuegt
- LLM Compare Seiten, Configs und alle Referenzen geloescht
- Kommunikation-Kategorie in Sidebar mit Video & Chat, Voice Service, Alerts
- Compliance SDK Kategorie aus Sidebar entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:34:54 +01:00

1035 lines
40 KiB
TypeScript

'use client'
/**
* RBAC Management Page
*
* Features:
* - Multi-tenant management
* - Namespace-based isolation (CFO use case)
* - Role management with permissions
* - User-Role assignments with scope
* - LLM access policies
*/
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
// Types
interface Tenant {
id: string
name: string
slug: string
settings: Record<string, any>
max_users: number
llm_quota_monthly: number
status: string
created_at: string
}
interface Namespace {
id: string
tenant_id: string
name: string
slug: string
parent_namespace_id: string | null
isolation_level: string
data_classification: string
metadata: Record<string, any>
created_at: string
}
interface Role {
id: string
tenant_id: string | null
name: string
description: string
permissions: string[]
is_system_role: boolean
hierarchy_level: number
created_at: string
}
interface UserRole {
id: string
user_id: string
role_id: string
role_name?: string
tenant_id: string
namespace_id: string | null
namespace_name?: string
granted_by: string
expires_at: string | null
created_at: string
}
interface LLMPolicy {
id: string
tenant_id: string
namespace_id: string | null
name: string
allowed_data_categories: string[]
blocked_data_categories: string[]
require_pii_redaction: boolean
pii_redaction_level: string
allowed_models: string[]
max_tokens_per_request: number
max_requests_per_day: number
created_at: string
}
// Tab configuration
type TabId = 'tenants' | 'namespaces' | 'roles' | 'users' | 'policies'
const TABS: { id: TabId; label: string; icon: string }[] = [
{ id: 'tenants', label: 'Mandanten', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
{ id: 'namespaces', label: 'Namespaces', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' },
{ id: 'roles', label: 'Rollen', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ id: 'users', label: 'Benutzer-Rollen', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ id: 'policies', label: 'LLM-Policies', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },
]
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-700',
inactive: 'bg-slate-100 text-slate-500',
suspended: 'bg-red-100 text-red-700',
}
const ISOLATION_COLORS: Record<string, string> = {
strict: 'bg-red-100 text-red-700',
standard: 'bg-yellow-100 text-yellow-700',
relaxed: 'bg-green-100 text-green-700',
}
const DATA_CLASSIFICATION_COLORS: Record<string, string> = {
restricted: 'bg-red-100 text-red-700',
confidential: 'bg-orange-100 text-orange-700',
internal: 'bg-yellow-100 text-yellow-700',
public: 'bg-green-100 text-green-700',
}
// SDK requests are proxied through nginx on the same origin (no CORS issues)
const SDK_BASE_URL = ''
export default function RBACPage() {
const [activeTab, setActiveTab] = useState<TabId>('tenants')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Data states
const [tenants, setTenants] = useState<Tenant[]>([])
const [namespaces, setNamespaces] = useState<Namespace[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [userRoles, setUserRoles] = useState<UserRole[]>([])
const [policies, setPolicies] = useState<LLMPolicy[]>([])
// Filter states
const [selectedTenantId, setSelectedTenantId] = useState<string>('')
const [searchTerm, setSearchTerm] = useState('')
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
// Load data based on active tab
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (selectedTenantId) {
headers['X-Tenant-ID'] = selectedTenantId
}
switch (activeTab) {
case 'tenants': {
const res = await fetch(`${SDK_BASE_URL}/sdk/v1/tenants`, { headers })
if (res.ok) {
const data = await res.json()
setTenants(data.tenants || [])
}
break
}
case 'namespaces': {
if (!selectedTenantId) {
setNamespaces([])
break
}
const res = await fetch(`${SDK_BASE_URL}/sdk/v1/tenants/${selectedTenantId}/namespaces`, { headers })
if (res.ok) {
const data = await res.json()
setNamespaces(data.namespaces || [])
}
break
}
case 'roles': {
const res = await fetch(`${SDK_BASE_URL}/sdk/v1/roles`, { headers })
if (res.ok) {
const data = await res.json()
setRoles(data.roles || [])
}
break
}
case 'users': {
// This would need a user ID - for now show empty
setUserRoles([])
break
}
case 'policies': {
const res = await fetch(`${SDK_BASE_URL}/sdk/v1/llm/policies`, { headers })
if (res.ok) {
const data = await res.json()
setPolicies(data.policies || [])
}
break
}
}
} catch (err) {
console.error('Failed to load data:', err)
setError('Verbindung zum AI Compliance SDK fehlgeschlagen. Stellen Sie sicher, dass der SDK-Service laeuft.')
} finally {
setLoading(false)
}
}, [activeTab, selectedTenantId])
useEffect(() => {
loadData()
}, [loadData])
// Load tenants on mount for the filter
useEffect(() => {
const loadTenants = async () => {
try {
const res = await fetch(`${SDK_BASE_URL}/sdk/v1/tenants`)
if (res.ok) {
const data = await res.json()
setTenants(data.tenants || [])
if (data.tenants?.length > 0 && !selectedTenantId) {
setSelectedTenantId(data.tenants[0].id)
}
}
} catch (err) {
console.error('Failed to load tenants:', err)
}
}
loadTenants()
}, [])
const handleCreate = async (data: any) => {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (selectedTenantId) {
headers['X-Tenant-ID'] = selectedTenantId
}
let endpoint = ''
switch (activeTab) {
case 'tenants':
endpoint = `${SDK_BASE_URL}/sdk/v1/tenants`
break
case 'namespaces':
endpoint = `${SDK_BASE_URL}/sdk/v1/tenants/${selectedTenantId}/namespaces`
break
case 'roles':
endpoint = `${SDK_BASE_URL}/sdk/v1/roles`
break
case 'policies':
endpoint = `${SDK_BASE_URL}/sdk/v1/llm/policies`
break
}
const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(data),
})
if (res.ok) {
setShowCreateModal(false)
loadData()
} else {
const errData = await res.json()
alert(`Fehler: ${errData.error || 'Unbekannter Fehler'}`)
}
} catch (err) {
console.error('Create failed:', err)
alert('Erstellen fehlgeschlagen')
}
}
const filteredData = () => {
const term = searchTerm.toLowerCase()
switch (activeTab) {
case 'tenants':
return tenants.filter(t =>
t.name.toLowerCase().includes(term) ||
t.slug.toLowerCase().includes(term)
)
case 'namespaces':
return namespaces.filter(n =>
n.name.toLowerCase().includes(term) ||
n.slug.toLowerCase().includes(term)
)
case 'roles':
return roles.filter(r =>
r.name.toLowerCase().includes(term) ||
r.description?.toLowerCase().includes(term)
)
case 'policies':
return policies.filter(p =>
p.name.toLowerCase().includes(term)
)
default:
return []
}
}
// Statistics
const stats = {
tenants: tenants.length,
namespaces: namespaces.length,
roles: roles.length,
systemRoles: roles.filter(r => r.is_system_role).length,
policies: policies.length,
activeUsers: userRoles.length,
}
return (
<div className="min-h-screen bg-slate-50 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">RBAC Management</h1>
<p className="text-slate-600">Rollen, Berechtigungen & LLM-Zugriffskontrolle</p>
</div>
<div className="flex items-center gap-3">
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Mandant waehlen...</option>
{tenants.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
</div>
{/* Page Purpose */}
<PagePurpose
title="RBAC Management"
purpose="Verwalten Sie Multi-Tenant RBAC (Role-Based Access Control) mit Namespace-Isolation. Definieren Sie wer welche KI-Funktionen nutzen darf und welche Daten analysiert werden duerfen. CFO kann Gehaltsdaten analysieren, Entwickler nicht."
audience={['Admin', 'DSB', 'Compliance Officer']}
gdprArticles={['Art. 25 (Privacy by Design)', 'Art. 32 (Sicherheit)']}
architecture={{
services: ['AI Compliance SDK (Go)', 'PostgreSQL'],
databases: ['compliance_tenants', 'compliance_namespaces', 'compliance_roles', 'compliance_llm_policies'],
}}
relatedPages={[
{ name: 'Audit Trail', href: '/sdk/audit-report', description: 'LLM-Operationen protokollieren' },
]}
/>
{/* Statistics */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<p className="text-sm text-slate-500">Mandanten</p>
<p className="text-2xl font-bold text-slate-900">{stats.tenants}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-blue-200">
<p className="text-sm text-blue-600">Namespaces</p>
<p className="text-2xl font-bold text-blue-700">{stats.namespaces}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-purple-200">
<p className="text-sm text-purple-600">Rollen</p>
<p className="text-2xl font-bold text-purple-700">{stats.roles}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-indigo-200">
<p className="text-sm text-indigo-600">System-Rollen</p>
<p className="text-2xl font-bold text-indigo-700">{stats.systemRoles}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-teal-200">
<p className="text-sm text-teal-600">LLM-Policies</p>
<p className="text-2xl font-bold text-teal-700">{stats.policies}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-green-200">
<p className="text-sm text-green-600">Zuweisungen</p>
<p className="text-2xl font-bold text-green-700">{stats.activeUsers}</p>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-xl shadow-sm border mb-6">
<div className="border-b">
<nav className="flex -mb-px">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-6 py-4 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={tab.icon} />
</svg>
{tab.label}
</button>
))}
</nav>
</div>
{/* Search and Actions */}
<div className="p-4 border-b flex items-center justify-between">
<div className="flex-1 max-w-md">
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="ml-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neu erstellen
</button>
</div>
{/* Content */}
<div className="p-4">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<>
{/* Tenants Tab */}
{activeTab === 'tenants' && (
<TenantsTable
tenants={filteredData() as Tenant[]}
onEdit={setEditItem}
/>
)}
{/* Namespaces Tab */}
{activeTab === 'namespaces' && (
<NamespacesTable
namespaces={filteredData() as Namespace[]}
onEdit={setEditItem}
/>
)}
{/* Roles Tab */}
{activeTab === 'roles' && (
<RolesTable
roles={filteredData() as Role[]}
onEdit={setEditItem}
/>
)}
{/* User Roles Tab */}
{activeTab === 'users' && (
<UserRolesTable
userRoles={userRoles}
onEdit={setEditItem}
/>
)}
{/* Policies Tab */}
{activeTab === 'policies' && (
<PoliciesTable
policies={filteredData() as LLMPolicy[]}
onEdit={setEditItem}
/>
)}
</>
)}
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateModal
type={activeTab}
tenantId={selectedTenantId}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
/>
)}
</div>
)
}
// Tenants Table Component
function TenantsTable({ tenants, onEdit }: { tenants: Tenant[]; onEdit: (t: Tenant) => void }) {
if (tenants.length === 0) {
return (
<div className="text-center py-12 text-slate-500">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p>Keine Mandanten vorhanden</p>
</div>
)
}
return (
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Slug</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Max Users</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">LLM Quota</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{tenants.map(tenant => (
<tr key={tenant.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-slate-900">{tenant.name}</td>
<td className="px-4 py-3 font-mono text-slate-600">{tenant.slug}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${STATUS_COLORS[tenant.status] || 'bg-slate-100'}`}>
{tenant.status}
</span>
</td>
<td className="px-4 py-3 text-center text-slate-600">{tenant.max_users}</td>
<td className="px-4 py-3 text-center text-slate-600">{tenant.llm_quota_monthly?.toLocaleString()}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onEdit(tenant)}
className="text-sm text-primary-600 hover:text-primary-700"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
// Namespaces Table Component
function NamespacesTable({ namespaces, onEdit }: { namespaces: Namespace[]; onEdit: (n: Namespace) => void }) {
if (namespaces.length === 0) {
return (
<div className="text-center py-12 text-slate-500">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<p>Keine Namespaces vorhanden. Waehlen Sie zuerst einen Mandanten.</p>
</div>
)
}
return (
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Slug</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Isolation</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Klassifizierung</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{namespaces.map(ns => (
<tr key={ns.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-slate-900">{ns.name}</td>
<td className="px-4 py-3 font-mono text-slate-600">{ns.slug}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${ISOLATION_COLORS[ns.isolation_level] || 'bg-slate-100'}`}>
{ns.isolation_level}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${DATA_CLASSIFICATION_COLORS[ns.data_classification] || 'bg-slate-100'}`}>
{ns.data_classification}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onEdit(ns)}
className="text-sm text-primary-600 hover:text-primary-700"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
// Roles Table Component
function RolesTable({ roles, onEdit }: { roles: Role[]; onEdit: (r: Role) => void }) {
if (roles.length === 0) {
return (
<div className="text-center py-12 text-slate-500">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<p>Keine Rollen vorhanden</p>
</div>
)
}
return (
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Hierarchie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Permissions</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{roles.map(role => (
<tr key={role.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-slate-900">{role.name}</td>
<td className="px-4 py-3 text-slate-600 truncate max-w-xs">{role.description || '-'}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
role.is_system_role ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600'
}`}>
{role.is_system_role ? 'System' : 'Custom'}
</span>
</td>
<td className="px-4 py-3 text-center text-slate-600">{role.hierarchy_level}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1 max-w-md">
{(role.permissions || []).slice(0, 3).map((p, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
{p}
</span>
))}
{(role.permissions || []).length > 3 && (
<span className="px-2 py-0.5 text-xs bg-slate-200 text-slate-600 rounded">
+{role.permissions.length - 3}
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onEdit(role)}
className="text-sm text-primary-600 hover:text-primary-700"
disabled={role.is_system_role}
>
{role.is_system_role ? 'Ansehen' : 'Bearbeiten'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
// User Roles Table Component
function UserRolesTable({ userRoles, onEdit }: { userRoles: UserRole[]; onEdit: (ur: UserRole) => void }) {
return (
<div className="text-center py-12 text-slate-500">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="mb-2">Benutzer-Rollen Zuweisung</p>
<p className="text-sm text-slate-400">
Waehlen Sie einen Benutzer aus, um dessen Rollen zu verwalten.
</p>
</div>
)
}
// Policies Table Component
function PoliciesTable({ policies, onEdit }: { policies: LLMPolicy[]; onEdit: (p: LLMPolicy) => void }) {
if (policies.length === 0) {
return (
<div className="text-center py-12 text-slate-500">
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p>Keine LLM-Policies vorhanden</p>
</div>
)
}
return (
<table className="w-full">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Erlaubte Kategorien</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Blockierte Kategorien</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">PII Redaction</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Max Tokens</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{policies.map(policy => (
<tr key={policy.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-slate-900">{policy.name}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(policy.allowed_data_categories || []).map((c, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded">
{c}
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(policy.blocked_data_categories || []).map((c, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded">
{c}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
policy.require_pii_redaction ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-500'
}`}>
{policy.require_pii_redaction ? policy.pii_redaction_level : 'aus'}
</span>
</td>
<td className="px-4 py-3 text-center text-slate-600">
{policy.max_tokens_per_request?.toLocaleString()}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onEdit(policy)}
className="text-sm text-primary-600 hover:text-primary-700"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
// Create Modal Component
function CreateModal({
type,
tenantId,
onClose,
onSave,
}: {
type: TabId
tenantId: string
onClose: () => void
onSave: (data: any) => void
}) {
const [formData, setFormData] = useState<any>({})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave(formData)
}
const renderForm = () => {
switch (type) {
case 'tenants':
return (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
required
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Slug</label>
<input
type="text"
required
value={formData.slug || ''}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max Users</label>
<input
type="number"
value={formData.max_users || 100}
onChange={(e) => setFormData({ ...formData, max_users: parseInt(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">LLM Quota/Monat</label>
<input
type="number"
value={formData.llm_quota_monthly || 10000}
onChange={(e) => setFormData({ ...formData, llm_quota_monthly: parseInt(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
</>
)
case 'namespaces':
return (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
required
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Slug</label>
<input
type="text"
required
value={formData.slug || ''}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Isolation Level</label>
<select
value={formData.isolation_level || 'strict'}
onChange={(e) => setFormData({ ...formData, isolation_level: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="strict">Strict</option>
<option value="standard">Standard</option>
<option value="relaxed">Relaxed</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Daten-Klassifizierung</label>
<select
value={formData.data_classification || 'internal'}
onChange={(e) => setFormData({ ...formData, data_classification: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</div>
</div>
</>
)
case 'roles':
return (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
required
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Permissions (komma-separiert)</label>
<input
type="text"
value={formData.permissions?.join(', ') || ''}
onChange={(e) => setFormData({ ...formData, permissions: e.target.value.split(',').map((s: string) => s.trim()) })}
placeholder="compliance:read, llm:query"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Hierarchie-Level (niedriger = hoeher)</label>
<input
type="number"
value={formData.hierarchy_level || 100}
onChange={(e) => setFormData({ ...formData, hierarchy_level: parseInt(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</>
)
case 'policies':
return (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
required
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Erlaubte Daten-Kategorien</label>
<input
type="text"
value={formData.allowed_data_categories?.join(', ') || ''}
onChange={(e) => setFormData({ ...formData, allowed_data_categories: e.target.value.split(',').map((s: string) => s.trim()) })}
placeholder="salary, compensation, finance"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Blockierte Daten-Kategorien</label>
<input
type="text"
value={formData.blocked_data_categories?.join(', ') || ''}
onChange={(e) => setFormData({ ...formData, blocked_data_categories: e.target.value.split(',').map((s: string) => s.trim()) })}
placeholder="health, personal, salary"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.require_pii_redaction ?? true}
onChange={(e) => setFormData({ ...formData, require_pii_redaction: e.target.checked })}
className="rounded border-slate-300"
/>
<span className="text-sm text-slate-700">PII-Redaktion erforderlich</span>
</label>
{formData.require_pii_redaction && (
<select
value={formData.pii_redaction_level || 'strict'}
onChange={(e) => setFormData({ ...formData, pii_redaction_level: e.target.value })}
className="px-3 py-1 border rounded-lg text-sm"
>
<option value="strict">Strict</option>
<option value="moderate">Moderate</option>
<option value="minimal">Minimal</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 Tokens/Request</label>
<input
type="number"
value={formData.max_tokens_per_request || 4000}
onChange={(e) => setFormData({ ...formData, max_tokens_per_request: parseInt(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max Requests/Tag</label>
<input
type="number"
value={formData.max_requests_per_day || 1000}
onChange={(e) => setFormData({ ...formData, max_requests_per_day: parseInt(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
</>
)
default:
return <p>Nicht unterstuetzt</p>
}
}
const getTitle = () => {
switch (type) {
case 'tenants': return 'Neuer Mandant'
case 'namespaces': return 'Neuer Namespace'
case 'roles': return 'Neue Rolle'
case 'users': return 'Rolle zuweisen'
case 'policies': return 'Neue LLM-Policy'
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="p-6 border-b">
<h3 className="text-lg font-semibold text-slate-900">{getTitle()}</h3>
</div>
<form onSubmit={handleSubmit}>
<div className="p-6 space-y-4">
{renderForm()}
</div>
<div className="p-6 border-t bg-slate-50 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstellen
</button>
</div>
</form>
</div>
</div>
)
}