[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)
Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
302
admin-lehrer/app/(admin)/rbac/_components/CreateModal.tsx
Normal file
302
admin-lehrer/app/(admin)/rbac/_components/CreateModal.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { TabId } from '../types'
|
||||
|
||||
interface CreateModalProps {
|
||||
type: TabId
|
||||
tenantId: string
|
||||
onClose: () => void
|
||||
onSave: (data: any) => void
|
||||
}
|
||||
|
||||
function getTitle(type: TabId): string {
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function TenantForm({ formData, setFormData }: { formData: any; setFormData: (d: any) => void }) {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NamespaceForm({ formData, setFormData }: { formData: any; setFormData: (d: any) => void }) {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleForm({ formData, setFormData }: { formData: any; setFormData: (d: any) => void }) {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PolicyForm({ formData, setFormData }: { formData: any; setFormData: (d: any) => void }) {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateModal({ type, tenantId, onClose, onSave }: CreateModalProps) {
|
||||
const [formData, setFormData] = useState<any>({})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave(formData)
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
switch (type) {
|
||||
case 'tenants':
|
||||
return <TenantForm formData={formData} setFormData={setFormData} />
|
||||
case 'namespaces':
|
||||
return <NamespaceForm formData={formData} setFormData={setFormData} />
|
||||
case 'roles':
|
||||
return <RoleForm formData={formData} setFormData={setFormData} />
|
||||
case 'policies':
|
||||
return <PolicyForm formData={formData} setFormData={setFormData} />
|
||||
default:
|
||||
return <p>Nicht unterstuetzt</p>
|
||||
}
|
||||
}
|
||||
|
||||
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(type)}</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { Namespace } from '../types'
|
||||
import { ISOLATION_COLORS, DATA_CLASSIFICATION_COLORS } from '../types'
|
||||
|
||||
interface NamespacesTableProps {
|
||||
namespaces: Namespace[]
|
||||
onEdit: (n: Namespace) => void
|
||||
}
|
||||
|
||||
export function NamespacesTable({ namespaces, onEdit }: NamespacesTableProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
79
admin-lehrer/app/(admin)/rbac/_components/PoliciesTable.tsx
Normal file
79
admin-lehrer/app/(admin)/rbac/_components/PoliciesTable.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import type { LLMPolicy } from '../types'
|
||||
|
||||
interface PoliciesTableProps {
|
||||
policies: LLMPolicy[]
|
||||
onEdit: (p: LLMPolicy) => void
|
||||
}
|
||||
|
||||
export function PoliciesTable({ policies, onEdit }: PoliciesTableProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
75
admin-lehrer/app/(admin)/rbac/_components/RolesTable.tsx
Normal file
75
admin-lehrer/app/(admin)/rbac/_components/RolesTable.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { Role } from '../types'
|
||||
|
||||
interface RolesTableProps {
|
||||
roles: Role[]
|
||||
onEdit: (r: Role) => void
|
||||
}
|
||||
|
||||
export function RolesTable({ roles, onEdit }: RolesTableProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
60
admin-lehrer/app/(admin)/rbac/_components/TenantsTable.tsx
Normal file
60
admin-lehrer/app/(admin)/rbac/_components/TenantsTable.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { Tenant } from '../types'
|
||||
import { STATUS_COLORS } from '../types'
|
||||
|
||||
interface TenantsTableProps {
|
||||
tenants: Tenant[]
|
||||
onEdit: (t: Tenant) => void
|
||||
}
|
||||
|
||||
export function TenantsTable({ tenants, onEdit }: TenantsTableProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
22
admin-lehrer/app/(admin)/rbac/_components/UserRolesTable.tsx
Normal file
22
admin-lehrer/app/(admin)/rbac/_components/UserRolesTable.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import type { UserRole } from '../types'
|
||||
|
||||
interface UserRolesTableProps {
|
||||
userRoles: UserRole[]
|
||||
onEdit: (ur: UserRole) => void
|
||||
}
|
||||
|
||||
export function UserRolesTable({ userRoles, onEdit }: UserRolesTableProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -11,295 +11,36 @@
|
||||
* - 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 = ''
|
||||
import { useRbacData } from './useRbacData'
|
||||
import { TABS } from './types'
|
||||
import type { Tenant, Namespace, Role, LLMPolicy } from './types'
|
||||
import { TenantsTable } from './_components/TenantsTable'
|
||||
import { NamespacesTable } from './_components/NamespacesTable'
|
||||
import { RolesTable } from './_components/RolesTable'
|
||||
import { UserRolesTable } from './_components/UserRolesTable'
|
||||
import { PoliciesTable } from './_components/PoliciesTable'
|
||||
import { CreateModal } from './_components/CreateModal'
|
||||
|
||||
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,
|
||||
}
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
loading,
|
||||
error,
|
||||
tenants,
|
||||
userRoles,
|
||||
selectedTenantId,
|
||||
setSelectedTenantId,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
showCreateModal,
|
||||
setShowCreateModal,
|
||||
setEditItem,
|
||||
handleCreate,
|
||||
filteredData,
|
||||
stats,
|
||||
} = useRbacData()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
@@ -323,7 +64,6 @@ export default function RBACPage() {
|
||||
</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."
|
||||
@@ -340,33 +80,22 @@ export default function RBACPage() {
|
||||
|
||||
{/* 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>
|
||||
{[
|
||||
{ label: 'Mandanten', value: stats.tenants, border: 'border-slate-200', text: 'text-slate-500', bold: 'text-slate-900' },
|
||||
{ label: 'Namespaces', value: stats.namespaces, border: 'border-blue-200', text: 'text-blue-600', bold: 'text-blue-700' },
|
||||
{ label: 'Rollen', value: stats.roles, border: 'border-purple-200', text: 'text-purple-600', bold: 'text-purple-700' },
|
||||
{ label: 'System-Rollen', value: stats.systemRoles, border: 'border-indigo-200', text: 'text-indigo-600', bold: 'text-indigo-700' },
|
||||
{ label: 'LLM-Policies', value: stats.policies, border: 'border-teal-200', text: 'text-teal-600', bold: 'text-teal-700' },
|
||||
{ label: 'Zuweisungen', value: stats.activeUsers, border: 'border-green-200', text: 'text-green-600', bold: 'text-green-700' },
|
||||
].map(s => (
|
||||
<div key={s.label} className={`bg-white rounded-xl p-4 border ${s.border}`}>
|
||||
<p className={`text-sm ${s.text}`}>{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.bold}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* Tabs + Content */}
|
||||
<div className="bg-white rounded-xl shadow-sm border mb-6">
|
||||
<div className="border-b">
|
||||
<nav className="flex -mb-px">
|
||||
@@ -425,45 +154,11 @@ export default function RBACPage() {
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tenants' && <TenantsTable tenants={filteredData() as Tenant[]} onEdit={setEditItem} />}
|
||||
{activeTab === 'namespaces' && <NamespacesTable namespaces={filteredData() as Namespace[]} onEdit={setEditItem} />}
|
||||
{activeTab === 'roles' && <RolesTable roles={filteredData() as Role[]} onEdit={setEditItem} />}
|
||||
{activeTab === 'users' && <UserRolesTable userRoles={userRoles} onEdit={setEditItem} />}
|
||||
{activeTab === 'policies' && <PoliciesTable policies={filteredData() as LLMPolicy[]} onEdit={setEditItem} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -481,554 +176,3 @@ export default function RBACPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
97
admin-lehrer/app/(admin)/rbac/types.ts
Normal file
97
admin-lehrer/app/(admin)/rbac/types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* RBAC Management Types & Constants
|
||||
*/
|
||||
|
||||
export interface Tenant {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
settings: Record<string, any>
|
||||
max_users: number
|
||||
llm_quota_monthly: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
name: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
is_system_role: boolean
|
||||
hierarchy_level: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export type TabId = 'tenants' | 'namespaces' | 'roles' | 'users' | 'policies'
|
||||
|
||||
export 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' },
|
||||
]
|
||||
|
||||
export 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',
|
||||
}
|
||||
|
||||
export 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',
|
||||
}
|
||||
|
||||
export 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)
|
||||
export const SDK_BASE_URL = ''
|
||||
225
admin-lehrer/app/(admin)/rbac/useRbacData.ts
Normal file
225
admin-lehrer/app/(admin)/rbac/useRbacData.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { TabId, Tenant, Namespace, Role, UserRole, LLMPolicy } from './types'
|
||||
import { SDK_BASE_URL } from './types'
|
||||
|
||||
export interface RbacStats {
|
||||
tenants: number
|
||||
namespaces: number
|
||||
roles: number
|
||||
systemRoles: number
|
||||
policies: number
|
||||
activeUsers: number
|
||||
}
|
||||
|
||||
export function useRbacData() {
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
const stats: RbacStats = {
|
||||
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 {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
loading,
|
||||
error,
|
||||
tenants,
|
||||
userRoles,
|
||||
selectedTenantId,
|
||||
setSelectedTenantId,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
showCreateModal,
|
||||
setShowCreateModal,
|
||||
editItem,
|
||||
setEditItem,
|
||||
handleCreate,
|
||||
filteredData,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user