refactor(admin): split rbac page.tsx into colocated components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-14 22:50:55 +02:00
parent 82a5a388b8
commit d5287f4bdd
15 changed files with 918 additions and 801 deletions

View File

@@ -0,0 +1,23 @@
// =============================================================================
// RBAC API HELPERS
// =============================================================================
export const API = '/api/sdk/v1/rbac'
export async function apiFetch<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${API}/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!res.ok) {
const err = await res.text()
throw new Error(`HTTP ${res.status}: ${err}`)
}
return res.json()
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
}

View File

@@ -0,0 +1,64 @@
'use client'
import React, { useState } from 'react'
import { ModalBase } from './ModalBase'
import { apiFetch } from '../_api'
export function AssignRoleModal({ onClose, onAssigned }: { onClose: () => void; onAssigned: () => void }) {
const [form, setForm] = useState({ user_id: '', role_id: '', namespace_id: '', expires_at: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.user_id || !form.role_id) { setError('User-ID und Rolle sind Pflichtfelder'); return }
setSaving(true)
try {
await apiFetch('user-roles', {
method: 'POST',
body: JSON.stringify({
user_id: form.user_id,
role_id: form.role_id,
namespace_id: form.namespace_id || null,
expires_at: form.expires_at || null,
}),
})
onAssigned()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Rolle zuweisen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User-ID</label>
<input type="text" value={form.user_id} onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}
placeholder="UUID" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rollen-ID</label>
<input type="text" value={form.role_id} onChange={e => setForm(f => ({ ...f, role_id: e.target.value }))}
placeholder="UUID" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Namespace-ID (optional)</label>
<input type="text" value={form.namespace_id} onChange={e => setForm(f => ({ ...f, namespace_id: e.target.value }))}
placeholder="Leer = Global" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ablaufdatum (optional)</label>
<input type="date" value={form.expires_at} onChange={e => setForm(f => ({ ...f, expires_at: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Zuweisen'}
</button>
</div>
</ModalBase>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import React, { useState } from 'react'
import { ModalBase } from './ModalBase'
import { apiFetch } from '../_api'
export function CreateNamespaceModal({ tenantId, onClose, onCreated }: { tenantId: string; onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({ name: '', slug: '', isolation_level: 'shared', classification: 'internal' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name) { setError('Name ist Pflichtfeld'); return }
setSaving(true)
try {
await apiFetch(`tenants/${tenantId}/namespaces`, { method: 'POST', body: JSON.stringify(form) })
onCreated()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Namespace erstellen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input type="text" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Isolation</label>
<select value={form.isolation_level} onChange={e => setForm(f => ({ ...f, isolation_level: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="shared">Shared</option>
<option value="isolated">Isolated</option>
<option value="strict">Strict</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Klassifikation</label>
<select value={form.classification} onChange={e => setForm(f => ({ ...f, classification: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</ModalBase>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import React, { useState } from 'react'
import { ModalBase } from './ModalBase'
import { apiFetch } from '../_api'
export function CreateRoleModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({ name: '', description: '', permissions: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name) { setError('Name ist Pflichtfeld'); return }
setSaving(true)
try {
await apiFetch('roles', {
method: 'POST',
body: JSON.stringify({
name: form.name,
description: form.description,
permissions: form.permissions.split(',').map(s => s.trim()).filter(Boolean),
}),
})
onCreated()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Rolle erstellen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Berechtigungen (kommagetrennt)</label>
<textarea value={form.permissions} onChange={e => setForm(f => ({ ...f, permissions: e.target.value }))}
placeholder="llm:read, llm:write, audit:read"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono h-20" />
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</ModalBase>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import React, { useState } from 'react'
import { ModalBase } from './ModalBase'
import { apiFetch } from '../_api'
export function CreateTenantModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({ name: '', slug: '', user_limit: 100, llm_quota: 100000 })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name || !form.slug) { setError('Name und Slug sind Pflichtfelder'); return }
setSaving(true)
try {
await apiFetch('tenants', { method: 'POST', body: JSON.stringify(form) })
onCreated()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Mandant anlegen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input type="text" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User-Limit</label>
<input type="number" value={form.user_limit} onChange={e => setForm(f => ({ ...f, user_limit: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">LLM-Quota</label>
<input type="number" value={form.llm_quota} onChange={e => setForm(f => ({ ...f, llm_quota: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</ModalBase>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
import React from 'react'
export function ModalBase({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
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 max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-gray-200">
<h3 className="text-lg font-semibold">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import React from 'react'
import type { Tenant, Namespace } from '../_types'
import { formatDate } from '../_api'
interface Props {
tenants: Tenant[]
namespaces: Namespace[]
selectedTenantId: string | null
onSelectTenant: (id: string) => void
onOpenCreate: () => void
onLoadNamespaces: (tenantId: string) => void
}
export function NamespacesTab({ tenants, namespaces, selectedTenantId, onSelectTenant, onOpenCreate, onLoadNamespaces }: Props) {
return (
<div>
<div className="flex items-center gap-4 mb-4">
<select
value={selectedTenantId || ''}
onChange={e => { onSelectTenant(e.target.value); if (e.target.value) onLoadNamespaces(e.target.value) }}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
>
<option value="">Mandant waehlen...</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{selectedTenantId && (
<button
onClick={onOpenCreate}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Namespace
</button>
)}
</div>
{!selectedTenantId ? (
<div className="text-center py-12 text-gray-400">Bitte einen Mandanten waehlen</div>
) : (
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Slug</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Isolation</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Klassifikation</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Erstellt</th>
</tr>
</thead>
<tbody>
{namespaces.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">Keine Namespaces</td></tr>
) : namespaces.map(ns => (
<tr key={ns.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{ns.name}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">{ns.slug}</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{ns.isolation_level}</span>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-gray-100 text-gray-700 rounded text-xs">{ns.classification}</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(ns.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import React from 'react'
import type { LLMPolicy } from '../_types'
interface Props {
policies: LLMPolicy[]
onCreate: () => void
onEdit: (p: LLMPolicy) => void
onDelete: (id: string) => void
onToggleActive: (p: LLMPolicy) => void
}
export function PoliciesTab({ policies, onCreate, onEdit, onDelete, onToggleActive }: Props) {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">{policies.length} LLM-Policies</h2>
<button
onClick={onCreate}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Policy erstellen
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{policies.map(policy => (
<div key={policy.id} className={`bg-white rounded-xl border p-5 ${
policy.is_active ? 'border-gray-200' : 'border-gray-100 opacity-60'
}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{policy.name}</h3>
<p className="text-xs text-gray-500 mt-0.5">{policy.description}</p>
</div>
<button
onClick={() => onToggleActive(policy)}
className={`px-2 py-0.5 rounded text-xs font-medium ${
policy.is_active ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
}`}
>
{policy.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</div>
<div className="space-y-2 text-sm">
{(policy.allowed_models || []).length > 0 && (
<div>
<span className="text-gray-500">Erlaubte Models: </span>
<div className="flex flex-wrap gap-1 mt-1">
{policy.allowed_models.map(m => (
<span key={m} className="px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{m}</span>
))}
</div>
</div>
)}
<div className="flex justify-between text-gray-600">
<span>Rate-Limit</span>
<span className="font-mono text-xs">{policy.rate_limit_rpm} req/min, {policy.rate_limit_tpd} tok/tag</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Max Tokens/Request</span>
<span className="font-mono text-xs">{policy.max_tokens_per_request}</span>
</div>
<div className="flex gap-3">
{policy.pii_detection_required && (
<span className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">PII-Erkennung</span>
)}
{policy.pii_redaction_required && (
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs">PII-Redaktion</span>
)}
</div>
</div>
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-gray-100">
<button
onClick={() => onEdit(policy)}
className="text-sm text-purple-600 hover:text-purple-700"
>
Bearbeiten
</button>
<button
onClick={() => onDelete(policy.id)}
className="text-sm text-red-600 hover:text-red-700"
>
Loeschen
</button>
</div>
</div>
))}
</div>
{policies.length === 0 && (
<div className="text-center py-12 text-gray-400">Keine LLM-Policies vorhanden</div>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import React, { useState } from 'react'
import { ModalBase } from './ModalBase'
import { apiFetch } from '../_api'
import type { LLMPolicy } from '../_types'
export function PolicyModal({ existing, onClose, onSaved }: { existing: LLMPolicy | null; onClose: () => void; onSaved: () => void }) {
const [form, setForm] = useState({
name: existing?.name || '',
description: existing?.description || '',
allowed_models: (existing?.allowed_models || []).join(', '),
blocked_models: (existing?.blocked_models || []).join(', '),
rate_limit_rpm: existing?.rate_limit_rpm ?? 60,
rate_limit_tpd: existing?.rate_limit_tpd ?? 1000000,
max_tokens_per_request: existing?.max_tokens_per_request ?? 4096,
pii_detection_required: existing?.pii_detection_required ?? true,
pii_redaction_required: existing?.pii_redaction_required ?? false,
is_active: existing?.is_active ?? true,
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name) { setError('Name ist Pflichtfeld'); return }
setSaving(true)
try {
const body = {
...form,
allowed_models: form.allowed_models.split(',').map(s => s.trim()).filter(Boolean),
blocked_models: form.blocked_models.split(',').map(s => s.trim()).filter(Boolean),
}
if (existing) {
await apiFetch(`llm/policies/${existing.id}`, { method: 'PUT', body: JSON.stringify(body) })
} else {
await apiFetch('llm/policies', { method: 'POST', body: JSON.stringify(body) })
}
onSaved()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title={existing ? 'Policy bearbeiten' : 'Policy erstellen'} onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Erlaubte Models (kommagetrennt)</label>
<input type="text" value={form.allowed_models} onChange={e => setForm(f => ({ ...f, allowed_models: e.target.value }))}
placeholder="qwen3:30b-a3b, claude-sonnet-4-5"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Blockierte Models (kommagetrennt)</label>
<input type="text" value={form.blocked_models} onChange={e => setForm(f => ({ ...f, blocked_models: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rate (req/min)</label>
<input type="number" value={form.rate_limit_rpm} onChange={e => setForm(f => ({ ...f, rate_limit_rpm: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tokens/Tag</label>
<input type="number" value={form.rate_limit_tpd} onChange={e => setForm(f => ({ ...f, rate_limit_tpd: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Tok/Req</label>
<input type="number" value={form.max_tokens_per_request} onChange={e => setForm(f => ({ ...f, max_tokens_per_request: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.pii_detection_required}
onChange={e => setForm(f => ({ ...f, pii_detection_required: e.target.checked }))}
className="rounded border-gray-300" />
PII-Erkennung
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.pii_redaction_required}
onChange={e => setForm(f => ({ ...f, pii_redaction_required: e.target.checked }))}
className="rounded border-gray-300" />
PII-Redaktion
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.is_active}
onChange={e => setForm(f => ({ ...f, is_active: e.target.checked }))}
className="rounded border-gray-300" />
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : (existing ? 'Aktualisieren' : 'Erstellen')}
</button>
</div>
</ModalBase>
)
}

View File

@@ -0,0 +1,64 @@
'use client'
import React from 'react'
import type { Role } from '../_types'
interface Props {
roles: Role[]
onOpenCreate: () => void
}
export function RolesTab({ roles, onOpenCreate }: Props) {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">{roles.length} Rollen</h2>
<button
onClick={onOpenCreate}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Rolle erstellen
</button>
</div>
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Beschreibung</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Typ</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Berechtigungen</th>
</tr>
</thead>
<tbody>
{roles.length === 0 ? (
<tr><td colSpan={4} className="text-center py-8 text-gray-400">Keine Rollen</td></tr>
) : roles.map(role => (
<tr key={role.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{role.name}</td>
<td className="px-4 py-3 text-gray-500 max-w-xs truncate">{role.description}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
role.is_system ? 'bg-purple-50 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}>
{role.is_system ? 'System' : 'Custom'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(role.permissions || []).slice(0, 4).map(p => (
<span key={p} className="px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{p}</span>
))}
{(role.permissions || []).length > 4 && (
<span className="text-xs text-gray-400">+{role.permissions.length - 4}</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import React from 'react'
import type { Tenant, TabId } from '../_types'
import { formatDate } from '../_api'
interface Props {
tenants: Tenant[]
onOpenCreate: () => void
onSelectTenant: (id: string) => void
setActiveTab: (tab: TabId) => void
}
export function TenantsTab({ tenants, onOpenCreate, onSelectTenant, setActiveTab }: Props) {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">{tenants.length} Mandanten</h2>
<button
onClick={onOpenCreate}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Mandant anlegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tenants.map(t => (
<div key={t.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{t.name}</h3>
<span className="text-xs font-mono text-gray-400">{t.slug}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
t.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{t.status}
</span>
</div>
<div className="space-y-1 text-sm text-gray-600">
<div className="flex justify-between">
<span>User-Limit</span>
<span className="font-mono">{t.user_limit || 'unbegrenzt'}</span>
</div>
<div className="flex justify-between">
<span>LLM-Quota</span>
<span className="font-mono">{t.llm_quota || 'unbegrenzt'}</span>
</div>
<div className="flex justify-between">
<span>Erstellt</span>
<span>{formatDate(t.created_at)}</span>
</div>
</div>
<button
onClick={() => { onSelectTenant(t.id); setActiveTab('namespaces') }}
className="mt-3 w-full text-center text-sm text-purple-600 hover:text-purple-700 py-1"
>
Namespaces anzeigen
</button>
</div>
))}
</div>
{tenants.length === 0 && (
<div className="text-center py-12 text-gray-400">Keine Mandanten vorhanden</div>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import React, { useState } from 'react'
export function UserRoleLookup({ onLoad }: { onLoad: (userId: string) => void }) {
const [userId, setUserId] = useState('00000000-0000-0000-0000-000000000001')
return (
<div className="flex gap-2">
<input
type="text"
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="User-ID eingeben..."
className="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 font-mono"
/>
<button
onClick={() => onLoad(userId)}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm"
>
Laden
</button>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import React from 'react'
import type { UserRole } from '../_types'
import { formatDate, apiFetch } from '../_api'
import { UserRoleLookup } from './UserRoleLookup'
interface Props {
userRoles: UserRole[]
onOpenAssign: () => void
onRevoke: (userId: string, roleId: string) => void
setUserRoles: (rows: UserRole[]) => void
setLoading: (v: boolean) => void
setError: (msg: string | null) => void
}
export function UsersTab({ userRoles, onOpenAssign, onRevoke, setUserRoles, setLoading, setError }: Props) {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Benutzer-Rollen</h2>
<button
onClick={onOpenAssign}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Rolle zuweisen
</button>
</div>
{/* User ID lookup */}
<div className="mb-4">
<UserRoleLookup onLoad={(userId) => {
setLoading(true)
apiFetch<UserRole[] | { roles: UserRole[] }>(`user-roles/${userId}`)
.then(data => setUserRoles(Array.isArray(data) ? data : data.roles || []))
.catch(e => setError(e instanceof Error ? e.message : 'Fehler'))
.finally(() => setLoading(false))
}} />
</div>
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">User-ID</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Rolle</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Namespace</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Ablauf</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{userRoles.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">Keine Rollen zugewiesen</td></tr>
) : userRoles.map(ur => (
<tr key={ur.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs">{ur.user_id}</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs font-medium">
{ur.role_name || ur.role_id}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">
{ur.namespace_id || 'Global'}
</td>
<td className="px-4 py-3 text-gray-500">
{ur.expires_at ? formatDate(ur.expires_at) : 'Unbegrenzt'}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => onRevoke(ur.user_id, ur.role_id)}
className="text-red-600 hover:text-red-700 text-xs"
>
Entziehen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
// =============================================================================
// RBAC TYPES
// =============================================================================
export interface Tenant {
id: string
name: string
slug: string
status: string
user_limit: number
llm_quota: number
settings: Record<string, unknown>
created_at: string
updated_at: string
}
export interface Namespace {
id: string
tenant_id: string
name: string
slug: string
isolation_level: string
classification: string
created_at: string
}
export interface Role {
id: string
name: string
description: string
is_system: boolean
permissions: string[]
created_at: string
}
export interface UserRole {
id: string
user_id: string
role_id: string
role_name: string
namespace_id: string | null
expires_at: string | null
created_at: string
}
export interface LLMPolicy {
id: string
name: string
description: string
tenant_id: string
namespace_id: string | null
allowed_models: string[]
blocked_models: string[]
rate_limit_rpm: number
rate_limit_tpd: number
pii_detection_required: boolean
pii_redaction_required: boolean
max_tokens_per_request: number
is_active: boolean
created_at: string
updated_at: string
}
export type TabId = 'tenants' | 'namespaces' | 'roles' | 'users' | 'policies'

View File

@@ -2,95 +2,18 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk' import { useSDK } from '@/lib/sdk'
import type { Tenant, Namespace, Role, UserRole, LLMPolicy, TabId } from './_types'
// ============================================================================= import { apiFetch } from './_api'
// TYPES import { CreateTenantModal } from './_components/CreateTenantModal'
// ============================================================================= import { CreateNamespaceModal } from './_components/CreateNamespaceModal'
import { CreateRoleModal } from './_components/CreateRoleModal'
interface Tenant { import { AssignRoleModal } from './_components/AssignRoleModal'
id: string import { PolicyModal } from './_components/PolicyModal'
name: string import { TenantsTab } from './_components/TenantsTab'
slug: string import { NamespacesTab } from './_components/NamespacesTab'
status: string import { RolesTab } from './_components/RolesTab'
user_limit: number import { UsersTab } from './_components/UsersTab'
llm_quota: number import { PoliciesTab } from './_components/PoliciesTab'
settings: Record<string, unknown>
created_at: string
updated_at: string
}
interface Namespace {
id: string
tenant_id: string
name: string
slug: string
isolation_level: string
classification: string
created_at: string
}
interface Role {
id: string
name: string
description: string
is_system: boolean
permissions: string[]
created_at: string
}
interface UserRole {
id: string
user_id: string
role_id: string
role_name: string
namespace_id: string | null
expires_at: string | null
created_at: string
}
interface LLMPolicy {
id: string
name: string
description: string
tenant_id: string
namespace_id: string | null
allowed_models: string[]
blocked_models: string[]
rate_limit_rpm: number
rate_limit_tpd: number
pii_detection_required: boolean
pii_redaction_required: boolean
max_tokens_per_request: number
is_active: boolean
created_at: string
updated_at: string
}
type TabId = 'tenants' | 'namespaces' | 'roles' | 'users' | 'policies'
// =============================================================================
// API
// =============================================================================
const API = '/api/sdk/v1/rbac'
async function apiFetch<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${API}/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!res.ok) {
const err = await res.text()
throw new Error(`HTTP ${res.status}: ${err}`)
}
return res.json()
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
}
// ============================================================================= // =============================================================================
// MAIN PAGE // MAIN PAGE
@@ -98,6 +21,7 @@ function formatDate(iso: string): string {
export default function RBACPage() { export default function RBACPage() {
const { state } = useSDK() const { state } = useSDK()
void state
const [activeTab, setActiveTab] = useState<TabId>('tenants') const [activeTab, setActiveTab] = useState<TabId>('tenants')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -255,333 +179,49 @@ export default function RBACPage() {
</div> </div>
)} )}
{/* ══════════════════════════════════════════════════════════════════ */}
{/* TENANTS TAB */}
{/* ══════════════════════════════════════════════════════════════════ */}
{!loading && activeTab === 'tenants' && ( {!loading && activeTab === 'tenants' && (
<div> <TenantsTab
<div className="flex justify-between items-center mb-4"> tenants={tenants}
<h2 className="text-lg font-semibold">{tenants.length} Mandanten</h2> onOpenCreate={() => setShowTenantModal(true)}
<button onSelectTenant={setSelectedTenantId}
onClick={() => setShowTenantModal(true)} setActiveTab={setActiveTab}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700" />
>
+ Mandant anlegen
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tenants.map(t => (
<div key={t.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{t.name}</h3>
<span className="text-xs font-mono text-gray-400">{t.slug}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
t.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{t.status}
</span>
</div>
<div className="space-y-1 text-sm text-gray-600">
<div className="flex justify-between">
<span>User-Limit</span>
<span className="font-mono">{t.user_limit || 'unbegrenzt'}</span>
</div>
<div className="flex justify-between">
<span>LLM-Quota</span>
<span className="font-mono">{t.llm_quota || 'unbegrenzt'}</span>
</div>
<div className="flex justify-between">
<span>Erstellt</span>
<span>{formatDate(t.created_at)}</span>
</div>
</div>
<button
onClick={() => { setSelectedTenantId(t.id); setActiveTab('namespaces') }}
className="mt-3 w-full text-center text-sm text-purple-600 hover:text-purple-700 py-1"
>
Namespaces anzeigen
</button>
</div>
))}
</div>
{tenants.length === 0 && (
<div className="text-center py-12 text-gray-400">Keine Mandanten vorhanden</div>
)}
</div>
)} )}
{/* ══════════════════════════════════════════════════════════════════ */}
{/* NAMESPACES TAB */}
{/* ══════════════════════════════════════════════════════════════════ */}
{!loading && activeTab === 'namespaces' && ( {!loading && activeTab === 'namespaces' && (
<div> <NamespacesTab
<div className="flex items-center gap-4 mb-4"> tenants={tenants}
<select namespaces={namespaces}
value={selectedTenantId || ''} selectedTenantId={selectedTenantId}
onChange={e => { setSelectedTenantId(e.target.value); if (e.target.value) loadNamespaces(e.target.value) }} onSelectTenant={setSelectedTenantId}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm" onOpenCreate={() => setShowNamespaceModal(true)}
> onLoadNamespaces={loadNamespaces}
<option value="">Mandant waehlen...</option> />
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{selectedTenantId && (
<button
onClick={() => setShowNamespaceModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Namespace
</button>
)}
</div>
{!selectedTenantId ? (
<div className="text-center py-12 text-gray-400">Bitte einen Mandanten waehlen</div>
) : (
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Slug</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Isolation</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Klassifikation</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Erstellt</th>
</tr>
</thead>
<tbody>
{namespaces.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">Keine Namespaces</td></tr>
) : namespaces.map(ns => (
<tr key={ns.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{ns.name}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">{ns.slug}</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{ns.isolation_level}</span>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-gray-100 text-gray-700 rounded text-xs">{ns.classification}</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(ns.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)} )}
{/* ══════════════════════════════════════════════════════════════════ */}
{/* ROLES TAB */}
{/* ══════════════════════════════════════════════════════════════════ */}
{!loading && activeTab === 'roles' && ( {!loading && activeTab === 'roles' && (
<div> <RolesTab roles={roles} onOpenCreate={() => setShowRoleModal(true)} />
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">{roles.length} Rollen</h2>
<button
onClick={() => setShowRoleModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
>
+ Rolle erstellen
</button>
</div>
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Beschreibung</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Typ</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Berechtigungen</th>
</tr>
</thead>
<tbody>
{roles.length === 0 ? (
<tr><td colSpan={4} className="text-center py-8 text-gray-400">Keine Rollen</td></tr>
) : roles.map(role => (
<tr key={role.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{role.name}</td>
<td className="px-4 py-3 text-gray-500 max-w-xs truncate">{role.description}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
role.is_system ? 'bg-purple-50 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}>
{role.is_system ? 'System' : 'Custom'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(role.permissions || []).slice(0, 4).map(p => (
<span key={p} className="px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{p}</span>
))}
{(role.permissions || []).length > 4 && (
<span className="text-xs text-gray-400">+{role.permissions.length - 4}</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)} )}
{/* ══════════════════════════════════════════════════════════════════ */}
{/* USERS TAB */}
{/* ══════════════════════════════════════════════════════════════════ */}
{!loading && activeTab === 'users' && ( {!loading && activeTab === 'users' && (
<div> <UsersTab
<div className="flex justify-between items-center mb-4"> userRoles={userRoles}
<h2 className="text-lg font-semibold">Benutzer-Rollen</h2> onOpenAssign={() => setShowUserRoleModal(true)}
<button onRevoke={revokeUserRole}
onClick={() => setShowUserRoleModal(true)} setUserRoles={setUserRoles}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700" setLoading={setLoading}
> setError={setError}
+ Rolle zuweisen />
</button>
</div>
{/* User ID lookup */}
<div className="mb-4">
<UserRoleLookup onLoad={(userId) => {
setLoading(true)
apiFetch<UserRole[] | { roles: UserRole[] }>(`user-roles/${userId}`)
.then(data => setUserRoles(Array.isArray(data) ? data : data.roles || []))
.catch(e => setError(e instanceof Error ? e.message : 'Fehler'))
.finally(() => setLoading(false))
}} />
</div>
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">User-ID</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Rolle</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Namespace</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Ablauf</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{userRoles.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">Keine Rollen zugewiesen</td></tr>
) : userRoles.map(ur => (
<tr key={ur.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs">{ur.user_id}</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs font-medium">
{ur.role_name || ur.role_id}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">
{ur.namespace_id || 'Global'}
</td>
<td className="px-4 py-3 text-gray-500">
{ur.expires_at ? formatDate(ur.expires_at) : 'Unbegrenzt'}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => revokeUserRole(ur.user_id, ur.role_id)}
className="text-red-600 hover:text-red-700 text-xs"
>
Entziehen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)} )}
{/* ══════════════════════════════════════════════════════════════════ */}
{/* LLM POLICIES TAB */}
{/* ══════════════════════════════════════════════════════════════════ */}
{!loading && activeTab === 'policies' && ( {!loading && activeTab === 'policies' && (
<div> <PoliciesTab
<div className="flex justify-between items-center mb-4"> policies={policies}
<h2 className="text-lg font-semibold">{policies.length} LLM-Policies</h2> onCreate={() => { setEditingPolicy(null); setShowPolicyModal(true) }}
<button onEdit={(policy) => { setEditingPolicy(policy); setShowPolicyModal(true) }}
onClick={() => { setEditingPolicy(null); setShowPolicyModal(true) }} onDelete={deleteLLMPolicy}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700" onToggleActive={togglePolicyActive}
> />
+ Policy erstellen
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{policies.map(policy => (
<div key={policy.id} className={`bg-white rounded-xl border p-5 ${
policy.is_active ? 'border-gray-200' : 'border-gray-100 opacity-60'
}`}>
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{policy.name}</h3>
<p className="text-xs text-gray-500 mt-0.5">{policy.description}</p>
</div>
<button
onClick={() => togglePolicyActive(policy)}
className={`px-2 py-0.5 rounded text-xs font-medium ${
policy.is_active ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
}`}
>
{policy.is_active ? 'Aktiv' : 'Inaktiv'}
</button>
</div>
<div className="space-y-2 text-sm">
{(policy.allowed_models || []).length > 0 && (
<div>
<span className="text-gray-500">Erlaubte Models: </span>
<div className="flex flex-wrap gap-1 mt-1">
{policy.allowed_models.map(m => (
<span key={m} className="px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{m}</span>
))}
</div>
</div>
)}
<div className="flex justify-between text-gray-600">
<span>Rate-Limit</span>
<span className="font-mono text-xs">{policy.rate_limit_rpm} req/min, {policy.rate_limit_tpd} tok/tag</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Max Tokens/Request</span>
<span className="font-mono text-xs">{policy.max_tokens_per_request}</span>
</div>
<div className="flex gap-3">
{policy.pii_detection_required && (
<span className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">PII-Erkennung</span>
)}
{policy.pii_redaction_required && (
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs">PII-Redaktion</span>
)}
</div>
</div>
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-gray-100">
<button
onClick={() => { setEditingPolicy(policy); setShowPolicyModal(true) }}
className="text-sm text-purple-600 hover:text-purple-700"
>
Bearbeiten
</button>
<button
onClick={() => deleteLLMPolicy(policy.id)}
className="text-sm text-red-600 hover:text-red-700"
>
Loeschen
</button>
</div>
</div>
))}
</div>
{policies.length === 0 && (
<div className="text-center py-12 text-gray-400">Keine LLM-Policies vorhanden</div>
)}
</div>
)} )}
{/* ══════════════════════════════════════════════════════════════════ */} {/* ══════════════════════════════════════════════════════════════════ */}
@@ -623,401 +263,3 @@ export default function RBACPage() {
</div> </div>
) )
} }
// =============================================================================
// USER ROLE LOOKUP
// =============================================================================
function UserRoleLookup({ onLoad }: { onLoad: (userId: string) => void }) {
const [userId, setUserId] = useState('00000000-0000-0000-0000-000000000001')
return (
<div className="flex gap-2">
<input
type="text"
value={userId}
onChange={e => setUserId(e.target.value)}
placeholder="User-ID eingeben..."
className="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 font-mono"
/>
<button
onClick={() => onLoad(userId)}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm"
>
Laden
</button>
</div>
)
}
// =============================================================================
// MODAL BASE
// =============================================================================
function ModalBase({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
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 max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-gray-200">
<h3 className="text-lg font-semibold">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
)
}
// =============================================================================
// CREATE TENANT MODAL
// =============================================================================
function CreateTenantModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({ name: '', slug: '', user_limit: 100, llm_quota: 100000 })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name || !form.slug) { setError('Name und Slug sind Pflichtfelder'); return }
setSaving(true)
try {
await apiFetch('tenants', { method: 'POST', body: JSON.stringify(form) })
onCreated()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Mandant anlegen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input type="text" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User-Limit</label>
<input type="number" value={form.user_limit} onChange={e => setForm(f => ({ ...f, user_limit: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">LLM-Quota</label>
<input type="number" value={form.llm_quota} onChange={e => setForm(f => ({ ...f, llm_quota: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</ModalBase>
)
}
// =============================================================================
// CREATE NAMESPACE MODAL
// =============================================================================
function CreateNamespaceModal({ tenantId, onClose, onCreated }: { tenantId: string; onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({ name: '', slug: '', isolation_level: 'shared', classification: 'internal' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name) { setError('Name ist Pflichtfeld'); return }
setSaving(true)
try {
await apiFetch(`tenants/${tenantId}/namespaces`, { method: 'POST', body: JSON.stringify(form) })
onCreated()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Namespace erstellen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input type="text" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Isolation</label>
<select value={form.isolation_level} onChange={e => setForm(f => ({ ...f, isolation_level: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="shared">Shared</option>
<option value="isolated">Isolated</option>
<option value="strict">Strict</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Klassifikation</label>
<select value={form.classification} onChange={e => setForm(f => ({ ...f, classification: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</ModalBase>
)
}
// =============================================================================
// CREATE ROLE MODAL
// =============================================================================
function CreateRoleModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [form, setForm] = useState({ name: '', description: '', permissions: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name) { setError('Name ist Pflichtfeld'); return }
setSaving(true)
try {
await apiFetch('roles', {
method: 'POST',
body: JSON.stringify({
name: form.name,
description: form.description,
permissions: form.permissions.split(',').map(s => s.trim()).filter(Boolean),
}),
})
onCreated()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Rolle erstellen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Berechtigungen (kommagetrennt)</label>
<textarea value={form.permissions} onChange={e => setForm(f => ({ ...f, permissions: e.target.value }))}
placeholder="llm:read, llm:write, audit:read"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono h-20" />
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Erstellen'}
</button>
</div>
</ModalBase>
)
}
// =============================================================================
// ASSIGN ROLE MODAL
// =============================================================================
function AssignRoleModal({ onClose, onAssigned }: { onClose: () => void; onAssigned: () => void }) {
const [form, setForm] = useState({ user_id: '', role_id: '', namespace_id: '', expires_at: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.user_id || !form.role_id) { setError('User-ID und Rolle sind Pflichtfelder'); return }
setSaving(true)
try {
await apiFetch('user-roles', {
method: 'POST',
body: JSON.stringify({
user_id: form.user_id,
role_id: form.role_id,
namespace_id: form.namespace_id || null,
expires_at: form.expires_at || null,
}),
})
onAssigned()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title="Rolle zuweisen" onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User-ID</label>
<input type="text" value={form.user_id} onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}
placeholder="UUID" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rollen-ID</label>
<input type="text" value={form.role_id} onChange={e => setForm(f => ({ ...f, role_id: e.target.value }))}
placeholder="UUID" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Namespace-ID (optional)</label>
<input type="text" value={form.namespace_id} onChange={e => setForm(f => ({ ...f, namespace_id: e.target.value }))}
placeholder="Leer = Global" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ablaufdatum (optional)</label>
<input type="date" value={form.expires_at} onChange={e => setForm(f => ({ ...f, expires_at: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Zuweisen'}
</button>
</div>
</ModalBase>
)
}
// =============================================================================
// POLICY MODAL (Create / Edit)
// =============================================================================
function PolicyModal({ existing, onClose, onSaved }: { existing: LLMPolicy | null; onClose: () => void; onSaved: () => void }) {
const [form, setForm] = useState({
name: existing?.name || '',
description: existing?.description || '',
allowed_models: (existing?.allowed_models || []).join(', '),
blocked_models: (existing?.blocked_models || []).join(', '),
rate_limit_rpm: existing?.rate_limit_rpm ?? 60,
rate_limit_tpd: existing?.rate_limit_tpd ?? 1000000,
max_tokens_per_request: existing?.max_tokens_per_request ?? 4096,
pii_detection_required: existing?.pii_detection_required ?? true,
pii_redaction_required: existing?.pii_redaction_required ?? false,
is_active: existing?.is_active ?? true,
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!form.name) { setError('Name ist Pflichtfeld'); return }
setSaving(true)
try {
const body = {
...form,
allowed_models: form.allowed_models.split(',').map(s => s.trim()).filter(Boolean),
blocked_models: form.blocked_models.split(',').map(s => s.trim()).filter(Boolean),
}
if (existing) {
await apiFetch(`llm/policies/${existing.id}`, { method: 'PUT', body: JSON.stringify(body) })
} else {
await apiFetch('llm/policies', { method: 'POST', body: JSON.stringify(body) })
}
onSaved()
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setSaving(false) }
}
return (
<ModalBase title={existing ? 'Policy bearbeiten' : 'Policy erstellen'} onClose={onClose}>
{error && <div className="mb-3 p-2 bg-red-50 text-red-700 rounded text-sm">{error}</div>}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Erlaubte Models (kommagetrennt)</label>
<input type="text" value={form.allowed_models} onChange={e => setForm(f => ({ ...f, allowed_models: e.target.value }))}
placeholder="qwen3:30b-a3b, claude-sonnet-4-5"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Blockierte Models (kommagetrennt)</label>
<input type="text" value={form.blocked_models} onChange={e => setForm(f => ({ ...f, blocked_models: e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rate (req/min)</label>
<input type="number" value={form.rate_limit_rpm} onChange={e => setForm(f => ({ ...f, rate_limit_rpm: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tokens/Tag</label>
<input type="number" value={form.rate_limit_tpd} onChange={e => setForm(f => ({ ...f, rate_limit_tpd: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Tok/Req</label>
<input type="number" value={form.max_tokens_per_request} onChange={e => setForm(f => ({ ...f, max_tokens_per_request: +e.target.value }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.pii_detection_required}
onChange={e => setForm(f => ({ ...f, pii_detection_required: e.target.checked }))}
className="rounded border-gray-300" />
PII-Erkennung
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.pii_redaction_required}
onChange={e => setForm(f => ({ ...f, pii_redaction_required: e.target.checked }))}
className="rounded border-gray-300" />
PII-Redaktion
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.is_active}
onChange={e => setForm(f => ({ ...f, is_active: e.target.checked }))}
className="rounded border-gray-300" />
Aktiv
</label>
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Abbrechen</button>
<button onClick={handleSubmit} disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : (existing ? 'Aktualisieren' : 'Erstellen')}
</button>
</div>
</ModalBase>
)
}