Remove duplicate compliance and DSGVO admin pages that have been superseded by the unified SDK pipeline. Update navigation, sidebar, roles, and module registry to reflect the new structure. Add DSFA corpus API proxy and source-policy components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1036 lines
40 KiB
TypeScript
1036 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' },
|
|
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
|
]}
|
|
/>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|