- 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>
1035 lines
40 KiB
TypeScript
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>
|
|
)
|
|
}
|