This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/app/(admin)/rbac/page.tsx
BreakPilot Dev f09e24d52c refactor(admin-v2): Consolidate compliance/DSGVO pages into SDK pipeline
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>
2026-02-10 23:26:05 +01:00

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>
)
}