Remove Compliance SDK category from sidebar navigation as it is now handled exclusively in the Compliance Admin. Add new SDK modules (DSB Portal, Industry Templates, Multi-Tenant, Reporting, SSO) and GCI engine components. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1483 lines
53 KiB
TypeScript
1483 lines
53 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Shield,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
ExternalLink,
|
|
Copy,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
Users,
|
|
Key,
|
|
Globe,
|
|
Info,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
TestTube,
|
|
RefreshCw,
|
|
X,
|
|
Eye,
|
|
EyeOff,
|
|
AlertTriangle
|
|
} from 'lucide-react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface SSOConfig {
|
|
id: string
|
|
tenant_id: string
|
|
provider_type: 'oidc' | 'saml'
|
|
name: string
|
|
enabled: boolean
|
|
oidc_issuer_url: string
|
|
oidc_client_id: string
|
|
oidc_client_secret: string
|
|
oidc_redirect_uri: string
|
|
oidc_scopes: string[]
|
|
role_mapping: Record<string, string>
|
|
default_role_id: string | null
|
|
auto_provision: boolean
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface RoleMapping {
|
|
sso_group: string
|
|
internal_role: string
|
|
}
|
|
|
|
interface SSOUser {
|
|
id: string
|
|
tenant_id: string
|
|
sso_config_id: string
|
|
external_id: string
|
|
email: string
|
|
display_name: string
|
|
groups: string[]
|
|
last_login: string | null
|
|
is_active: boolean
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface SSOFormData {
|
|
name: string
|
|
provider_type: 'oidc'
|
|
issuer_url: string
|
|
client_id: string
|
|
client_secret: string
|
|
redirect_uri: string
|
|
scopes: string
|
|
auto_provision: boolean
|
|
default_role: string
|
|
role_mappings: RoleMapping[]
|
|
}
|
|
|
|
interface ConnectionTestResult {
|
|
success: boolean
|
|
message: string
|
|
details?: {
|
|
issuer_reachable: boolean
|
|
jwks_available: boolean
|
|
authorization_endpoint: string
|
|
token_endpoint: string
|
|
}
|
|
}
|
|
|
|
type TabId = 'configs' | 'users' | 'info'
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
const FALLBACK_TENANT_ID = '00000000-0000-0000-0000-000000000001'
|
|
const FALLBACK_USER_ID = '00000000-0000-0000-0000-000000000001'
|
|
const API_BASE = '/api/sdk/v1/sso'
|
|
|
|
const DEFAULT_SCOPES = 'openid,email,profile'
|
|
|
|
const AVAILABLE_ROLES = [
|
|
{ value: 'user', label: 'Benutzer' },
|
|
{ value: 'admin', label: 'Administrator' },
|
|
{ value: 'data_protection_officer', label: 'Datenschutzbeauftragter' },
|
|
{ value: 'auditor', label: 'Auditor' },
|
|
{ value: 'viewer', label: 'Nur Lesen' },
|
|
]
|
|
|
|
const EMPTY_FORM: SSOFormData = {
|
|
name: '',
|
|
provider_type: 'oidc',
|
|
issuer_url: '',
|
|
client_id: '',
|
|
client_secret: '',
|
|
redirect_uri: '',
|
|
scopes: DEFAULT_SCOPES,
|
|
auto_provision: true,
|
|
default_role: 'user',
|
|
role_mappings: [],
|
|
}
|
|
|
|
// =============================================================================
|
|
// HELPERS
|
|
// =============================================================================
|
|
|
|
function getTenantId(): string {
|
|
if (typeof window !== 'undefined') {
|
|
return localStorage.getItem('sdk-tenant-id') || FALLBACK_TENANT_ID
|
|
}
|
|
return FALLBACK_TENANT_ID
|
|
}
|
|
|
|
function getUserId(): string {
|
|
if (typeof window !== 'undefined') {
|
|
return localStorage.getItem('sdk-user-id') || FALLBACK_USER_ID
|
|
}
|
|
return FALLBACK_USER_ID
|
|
}
|
|
|
|
function getDefaultRedirectUri(): string {
|
|
if (typeof window !== 'undefined') {
|
|
return `${window.location.origin}/api/sdk/v1/sso/oidc/callback`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
async function apiFetch<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': getTenantId(),
|
|
'X-User-ID': getUserId(),
|
|
...options.headers,
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}))
|
|
throw new Error(body.error || body.message || `HTTP ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
// =============================================================================
|
|
// SUB-COMPONENTS
|
|
// =============================================================================
|
|
|
|
function LoadingSkeleton({ rows = 3 }: { rows?: number }) {
|
|
return (
|
|
<div className="space-y-4 animate-pulse">
|
|
{Array.from({ length: rows }).map((_, i) => (
|
|
<div key={i} className="h-16 bg-slate-100 rounded-lg" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyState({
|
|
icon,
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
icon: React.ReactNode
|
|
title: string
|
|
description: string
|
|
action?: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<div className="w-16 h-16 mx-auto bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
|
{icon}
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
|
<p className="mt-2 text-sm text-slate-500 max-w-md mx-auto">{description}</p>
|
|
{action && <div className="mt-4">{action}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatusBadge({ enabled }: { enabled: boolean }) {
|
|
return enabled ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
Aktiv
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-slate-100 text-slate-600 rounded-full">
|
|
<XCircle className="w-3 h-3" />
|
|
Deaktiviert
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function CopyButton({ value }: { value: string }) {
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const handleCopy = () => {
|
|
navigator.clipboard.writeText(value)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleCopy}
|
|
className="p-1 text-slate-400 hover:text-slate-600 transition-colors"
|
|
title="In Zwischenablage kopieren"
|
|
>
|
|
{copied ? <CheckCircle2 className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// SSO CONFIG FORM MODAL
|
|
// =============================================================================
|
|
|
|
function SSOConfigFormModal({
|
|
isOpen,
|
|
editConfig,
|
|
onClose,
|
|
onSave,
|
|
}: {
|
|
isOpen: boolean
|
|
editConfig: SSOConfig | null
|
|
onClose: () => void
|
|
onSave: (data: SSOFormData) => Promise<void>
|
|
}) {
|
|
const [formData, setFormData] = useState<SSOFormData>(EMPTY_FORM)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [showSecret, setShowSecret] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (editConfig) {
|
|
setFormData({
|
|
name: editConfig.name,
|
|
provider_type: 'oidc',
|
|
issuer_url: editConfig.oidc_issuer_url || '',
|
|
client_id: editConfig.oidc_client_id || '',
|
|
client_secret: '',
|
|
redirect_uri: editConfig.oidc_redirect_uri || '',
|
|
scopes: (editConfig.oidc_scopes || []).join(','),
|
|
auto_provision: editConfig.auto_provision,
|
|
default_role: editConfig.default_role_id || 'user',
|
|
role_mappings: Object.entries(editConfig.role_mapping || {}).map(([k, v]) => ({ sso_group: k, internal_role: v })),
|
|
})
|
|
} else {
|
|
setFormData({
|
|
...EMPTY_FORM,
|
|
redirect_uri: getDefaultRedirectUri(),
|
|
})
|
|
}
|
|
setError(null)
|
|
setShowSecret(false)
|
|
}, [editConfig, isOpen])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setError(null)
|
|
|
|
if (!formData.name.trim()) {
|
|
setError('Name ist erforderlich')
|
|
return
|
|
}
|
|
if (!formData.issuer_url.trim()) {
|
|
setError('Issuer URL ist erforderlich')
|
|
return
|
|
}
|
|
if (!formData.client_id.trim()) {
|
|
setError('Client ID ist erforderlich')
|
|
return
|
|
}
|
|
if (!editConfig && !formData.client_secret.trim()) {
|
|
setError('Client Secret ist erforderlich')
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
try {
|
|
await onSave(formData)
|
|
onClose()
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Speichern')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const addRoleMapping = () => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
role_mappings: [...prev.role_mappings, { sso_group: '', internal_role: 'user' }],
|
|
}))
|
|
}
|
|
|
|
const updateRoleMapping = (index: number, field: keyof RoleMapping, value: string) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
role_mappings: prev.role_mappings.map((m, i) =>
|
|
i === index ? { ...m, [field]: value } : m
|
|
),
|
|
}))
|
|
}
|
|
|
|
const removeRoleMapping = (index: number) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
role_mappings: prev.role_mappings.filter((_, i) => i !== index),
|
|
}))
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between sticky top-0 bg-white rounded-t-xl z-10">
|
|
<h3 className="text-lg font-semibold text-slate-900">
|
|
{editConfig ? 'SSO-Konfiguration bearbeiten' : 'Neue SSO-Konfiguration'}
|
|
</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-slate-400 hover:text-slate-600 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
|
{error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm flex items-center gap-2">
|
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Provider Type */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Provider-Typ</label>
|
|
<select
|
|
value={formData.provider_type}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, provider_type: e.target.value as 'oidc' }))}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
<option value="oidc">OpenID Connect (OIDC)</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
placeholder="z.B. Azure AD, Okta, Keycloak"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Issuer URL */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">OIDC Issuer URL *</label>
|
|
<input
|
|
type="url"
|
|
value={formData.issuer_url}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, issuer_url: e.target.value }))}
|
|
placeholder="https://login.microsoftonline.com/{tenant-id}/v2.0"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Die Discovery-URL wird automatisch unter /.well-known/openid-configuration abgefragt
|
|
</p>
|
|
</div>
|
|
|
|
{/* Client ID */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Client ID *</label>
|
|
<input
|
|
type="text"
|
|
value={formData.client_id}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, client_id: e.target.value }))}
|
|
placeholder="Application (client) ID"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Client Secret */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Client Secret {editConfig ? '(leer lassen um beizubehalten)' : '*'}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showSecret ? 'text' : 'password'}
|
|
value={formData.client_secret}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, client_secret: e.target.value }))}
|
|
placeholder={editConfig ? '********' : 'Client Secret eingeben'}
|
|
className="w-full px-3 py-2 pr-10 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSecret(!showSecret)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
|
>
|
|
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Redirect URI */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Redirect URI</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="url"
|
|
value={formData.redirect_uri}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, redirect_uri: e.target.value }))}
|
|
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono bg-slate-50 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
<CopyButton value={formData.redirect_uri} />
|
|
</div>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Diese URI muss im Identity Provider als erlaubte Redirect-URI eingetragen werden
|
|
</p>
|
|
</div>
|
|
|
|
{/* Scopes */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Scopes</label>
|
|
<input
|
|
type="text"
|
|
value={formData.scopes}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, scopes: e.target.value }))}
|
|
placeholder="openid,email,profile"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Komma-getrennte Liste von OIDC Scopes
|
|
</p>
|
|
</div>
|
|
|
|
{/* Auto-Provision Toggle */}
|
|
<div className="flex items-center justify-between py-2">
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-700">Auto-Provisioning</label>
|
|
<p className="text-xs text-slate-400">
|
|
Benutzer automatisch beim ersten Login anlegen
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, auto_provision: !prev.auto_provision }))}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
formData.auto_provision ? 'bg-purple-600' : 'bg-slate-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
formData.auto_provision ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Default Role */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Standard-Rolle</label>
|
|
<select
|
|
value={formData.default_role}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, default_role: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
{AVAILABLE_ROLES.map(role => (
|
|
<option key={role.value} value={role.value}>{role.label}</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Rolle fuer neu provisionierte Benutzer ohne Rollen-Mapping
|
|
</p>
|
|
</div>
|
|
|
|
{/* Role Mappings */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-sm font-medium text-slate-700">Rollen-Mapping</label>
|
|
<button
|
|
type="button"
|
|
onClick={addRoleMapping}
|
|
className="text-xs text-purple-600 hover:text-purple-700 font-medium flex items-center gap-1"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
Mapping hinzufuegen
|
|
</button>
|
|
</div>
|
|
{formData.role_mappings.length === 0 ? (
|
|
<p className="text-xs text-slate-400 py-2">
|
|
Keine Rollen-Mappings konfiguriert. Alle Benutzer erhalten die Standard-Rolle.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{formData.role_mappings.map((mapping, idx) => (
|
|
<div key={idx} className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={mapping.sso_group}
|
|
onChange={(e) => updateRoleMapping(idx, 'sso_group', e.target.value)}
|
|
placeholder="SSO-Gruppe (z.B. admins)"
|
|
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
/>
|
|
<span className="text-slate-400 text-sm">→</span>
|
|
<select
|
|
value={mapping.internal_role}
|
|
onChange={(e) => updateRoleMapping(idx, 'internal_role', e.target.value)}
|
|
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
>
|
|
{AVAILABLE_ROLES.map(role => (
|
|
<option key={role.value} value={role.value}>{role.label}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeRoleMapping(idx)}
|
|
className="p-2 text-slate-400 hover:text-red-500 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="pt-4 border-t border-slate-200 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{editConfig ? 'Speichern' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// DELETE CONFIRMATION MODAL
|
|
// =============================================================================
|
|
|
|
function DeleteConfirmModal({
|
|
config,
|
|
onClose,
|
|
onConfirm,
|
|
}: {
|
|
config: SSOConfig | null
|
|
onClose: () => void
|
|
onConfirm: () => Promise<void>
|
|
}) {
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
if (!config) return null
|
|
|
|
const handleDelete = async () => {
|
|
setDeleting(true)
|
|
try {
|
|
await onConfirm()
|
|
onClose()
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-slate-900">Konfiguration loeschen?</h3>
|
|
<p className="text-sm text-slate-500">Diese Aktion kann nicht rueckgaengig gemacht werden.</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-slate-600 mb-6">
|
|
Die SSO-Konfiguration <strong>"{config.name}"</strong> wird unwiderruflich geloescht.
|
|
Alle ueber diese Konfiguration provisionierten Benutzer verlieren den SSO-Zugang.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
className="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{deleting && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Endgueltig loeschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONNECTION TEST PANEL
|
|
// =============================================================================
|
|
|
|
function ConnectionTestPanel({ config }: { config: SSOConfig }) {
|
|
const [testing, setTesting] = useState(false)
|
|
const [result, setResult] = useState<ConnectionTestResult | null>(null)
|
|
|
|
const runTest = async () => {
|
|
setTesting(true)
|
|
setResult(null)
|
|
try {
|
|
const data = await apiFetch<ConnectionTestResult>(`/configs/${config.id}/test`, {
|
|
method: 'POST',
|
|
})
|
|
setResult(data)
|
|
} catch (err: unknown) {
|
|
setResult({
|
|
success: false,
|
|
message: err instanceof Error ? err.message : 'Verbindungstest fehlgeschlagen',
|
|
})
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
const openLoginFlow = () => {
|
|
const loginUrl = `${API_BASE}/configs/${config.id}/login?tenant_id=${getTenantId()}`
|
|
window.open(loginUrl, '_blank', 'width=600,height=700')
|
|
}
|
|
|
|
return (
|
|
<div className="border border-slate-200 rounded-lg p-4 bg-slate-50">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<TestTube className="w-4 h-4" />
|
|
Verbindungstest
|
|
</h4>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={runTest}
|
|
disabled={testing}
|
|
className="px-3 py-1.5 text-xs text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50 transition-colors flex items-center gap-1 disabled:opacity-50"
|
|
>
|
|
{testing ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
|
|
Discovery testen
|
|
</button>
|
|
<button
|
|
onClick={openLoginFlow}
|
|
className="px-3 py-1.5 text-xs text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
Login testen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{result && (
|
|
<div className={`p-3 rounded-lg text-sm ${
|
|
result.success
|
|
? 'bg-green-50 border border-green-200 text-green-700'
|
|
: 'bg-red-50 border border-red-200 text-red-700'
|
|
}`}>
|
|
<div className="flex items-center gap-2 font-medium mb-1">
|
|
{result.success ? (
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
) : (
|
|
<XCircle className="w-4 h-4" />
|
|
)}
|
|
{result.message}
|
|
</div>
|
|
{result.details && (
|
|
<div className="mt-2 space-y-1 text-xs">
|
|
<div className="flex items-center gap-2">
|
|
{result.details.issuer_reachable ? (
|
|
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-3 h-3 text-red-500" />
|
|
)}
|
|
Issuer erreichbar
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{result.details.jwks_available ? (
|
|
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-3 h-3 text-red-500" />
|
|
)}
|
|
JWKS verfuegbar
|
|
</div>
|
|
{result.details.authorization_endpoint && (
|
|
<div className="flex items-center gap-2 text-slate-500 font-mono truncate">
|
|
Authorization: {result.details.authorization_endpoint}
|
|
</div>
|
|
)}
|
|
{result.details.token_endpoint && (
|
|
<div className="flex items-center gap-2 text-slate-500 font-mono truncate">
|
|
Token: {result.details.token_endpoint}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// SSO CONFIG CARD
|
|
// =============================================================================
|
|
|
|
function SSOConfigCard({
|
|
config,
|
|
onEdit,
|
|
onDelete,
|
|
onToggle,
|
|
}: {
|
|
config: SSOConfig
|
|
onEdit: () => void
|
|
onDelete: () => void
|
|
onToggle: () => void
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 hover:border-purple-300 transition-colors">
|
|
{/* Main Row */}
|
|
<div className="p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
|
<div className="w-10 h-10 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<Key className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
<h3 className="font-semibold text-slate-900">{config.name}</h3>
|
|
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-medium uppercase">
|
|
{config.provider_type}
|
|
</span>
|
|
<StatusBadge enabled={config.enabled} />
|
|
</div>
|
|
<p className="text-sm text-slate-500 font-mono truncate">{config.oidc_issuer_url}</p>
|
|
<div className="flex items-center gap-4 mt-2 text-xs text-slate-400">
|
|
<span>Client ID: {(config.oidc_client_id || '').substring(0, 12)}...</span>
|
|
<span>Scopes: {(config.oidc_scopes || []).join(', ')}</span>
|
|
<span>Erstellt: {formatDate(config.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
|
<button
|
|
onClick={onToggle}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
config.enabled ? 'bg-purple-600' : 'bg-slate-300'
|
|
}`}
|
|
title={config.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
config.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<button
|
|
onClick={onEdit}
|
|
className="p-2 text-slate-400 hover:text-purple-600 transition-colors"
|
|
title="Bearbeiten"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={onDelete}
|
|
className="p-2 text-slate-400 hover:text-red-500 transition-colors"
|
|
title="Loeschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="p-2 text-slate-400 hover:text-slate-600 transition-colors"
|
|
title={expanded ? 'Zuklappen' : 'Details anzeigen'}
|
|
>
|
|
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Details */}
|
|
{expanded && (
|
|
<div className="px-5 pb-5 space-y-4 border-t border-slate-100 pt-4">
|
|
{/* Config Details Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span className="text-slate-500">Redirect URI:</span>
|
|
<div className="flex items-center gap-1 mt-0.5">
|
|
<span className="font-mono text-slate-700 truncate text-xs">{config.oidc_redirect_uri}</span>
|
|
<CopyButton value={config.oidc_redirect_uri} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-500">Auto-Provisioning:</span>
|
|
<div className="mt-0.5 text-slate-700">
|
|
{config.auto_provision ? 'Aktiviert' : 'Deaktiviert'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-500">Standard-Rolle:</span>
|
|
<div className="mt-0.5 text-slate-700">
|
|
{config.default_role_id || 'Standard'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-500">Scopes:</span>
|
|
<div className="flex gap-1 mt-0.5 flex-wrap">
|
|
{(config.oidc_scopes || []).map(scope => (
|
|
<span key={scope} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
|
{scope}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Role Mappings */}
|
|
{config.role_mapping && Object.keys(config.role_mapping).length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-slate-700 mb-2">Rollen-Mapping</h4>
|
|
<div className="space-y-1">
|
|
{Object.entries(config.role_mapping).map(([group, role]) => (
|
|
<div key={group} className="flex items-center gap-2 text-sm">
|
|
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs font-mono">
|
|
{group}
|
|
</span>
|
|
<span className="text-slate-400">→</span>
|
|
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
|
{AVAILABLE_ROLES.find(r => r.value === role)?.label || role}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection Test */}
|
|
<ConnectionTestPanel config={config} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// SSO USERS TABLE
|
|
// =============================================================================
|
|
|
|
function SSOUsersTable({
|
|
users,
|
|
loading,
|
|
}: {
|
|
users: SSOUser[]
|
|
loading: boolean
|
|
}) {
|
|
if (loading) {
|
|
return <LoadingSkeleton rows={4} />
|
|
}
|
|
|
|
if (users.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={<Users className="w-8 h-8 text-slate-400" />}
|
|
title="Keine SSO-Benutzer"
|
|
description="Es wurden noch keine Benutzer ueber SSO provisioniert. Benutzer erscheinen hier nach dem ersten Login."
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-slate-200">
|
|
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
|
|
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">E-Mail</th>
|
|
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Externe ID</th>
|
|
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Gruppen</th>
|
|
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Letzter Login</th>
|
|
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map(user => (
|
|
<tr key={user.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
<td className="py-3 px-4">
|
|
<div className="font-medium text-slate-900">{user.display_name}</div>
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-slate-600">{user.email}</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-xs text-slate-500 font-mono">{user.external_id}</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{user.groups.length > 0 ? (
|
|
user.groups.map(group => (
|
|
<span key={group} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
|
{group}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-xs text-slate-400">-</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-slate-500">
|
|
{formatDate(user.last_login)}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{user.is_active ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
Aktiv
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-slate-100 text-slate-500 rounded-full">
|
|
<XCircle className="w-3 h-3" />
|
|
Inaktiv
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// INFO SECTION
|
|
// =============================================================================
|
|
|
|
function SSOInfoSection() {
|
|
const [expandedProvider, setExpandedProvider] = useState<string | null>(null)
|
|
|
|
const providers = [
|
|
{
|
|
id: 'azure',
|
|
name: 'Microsoft Entra ID (Azure AD)',
|
|
steps: [
|
|
'Melden Sie sich im Azure Portal (portal.azure.com) an',
|
|
'Navigieren Sie zu "Microsoft Entra ID" > "App-Registrierungen" > "Neue Registrierung"',
|
|
'Geben Sie einen Namen ein (z.B. "BreakPilot Comply SSO")',
|
|
'Waehlen Sie den Kontotyp (Einzelmandant oder Mehrere Mandanten)',
|
|
'Tragen Sie die Redirect URI ein (Typ: Web)',
|
|
'Notieren Sie die Application (Client) ID und die Directory (Tenant) ID',
|
|
'Erstellen Sie unter "Zertifikate & Geheimnisse" ein neues Client Secret',
|
|
'Issuer URL: https://login.microsoftonline.com/{Tenant-ID}/v2.0',
|
|
'Optional: Konfigurieren Sie Gruppen-Claims unter "Token-Konfiguration"',
|
|
],
|
|
},
|
|
{
|
|
id: 'okta',
|
|
name: 'Okta',
|
|
steps: [
|
|
'Melden Sie sich in der Okta Admin-Konsole an',
|
|
'Navigieren Sie zu "Applications" > "Create App Integration"',
|
|
'Waehlen Sie "OIDC - OpenID Connect" und "Web Application"',
|
|
'Geben Sie einen Namen und die Redirect URI ein',
|
|
'Notieren Sie die Client ID und das Client Secret',
|
|
'Issuer URL: https://{your-domain}.okta.com',
|
|
'Weisen Sie Benutzer/Gruppen der Applikation zu',
|
|
'Optional: Konfigurieren Sie Gruppen-Claims im Authorization Server',
|
|
],
|
|
},
|
|
{
|
|
id: 'keycloak',
|
|
name: 'Keycloak',
|
|
steps: [
|
|
'Melden Sie sich in der Keycloak Admin-Konsole an',
|
|
'Waehlen Sie den gewuenschten Realm oder erstellen Sie einen neuen',
|
|
'Navigieren Sie zu "Clients" > "Create client"',
|
|
'Client Type: "OpenID Connect", geben Sie eine Client ID ein',
|
|
'Aktivieren Sie "Client authentication" und "Standard flow"',
|
|
'Tragen Sie die Redirect URI unter "Valid redirect URIs" ein',
|
|
'Kopieren Sie das Client Secret aus dem Tab "Credentials"',
|
|
'Issuer URL: https://{host}/realms/{realm-name}',
|
|
'Optional: Konfigurieren Sie Gruppen-Mapper unter "Client scopes"',
|
|
],
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* How SSO Works */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
|
<Info className="w-5 h-5 text-purple-600" />
|
|
Wie funktioniert SSO?
|
|
</h3>
|
|
<div className="prose prose-sm prose-slate max-w-none">
|
|
<p className="text-sm text-slate-600">
|
|
Single Sign-On (SSO) ermoeglicht es Ihren Benutzern, sich mit den bestehenden Unternehmens-Zugangsdaten
|
|
anzumelden, ohne ein separates Passwort erstellen zu muessen. BreakPilot Comply unterstuetzt den
|
|
OpenID Connect (OIDC) Standard, der mit allen gaengigen Identity Providern kompatibel ist.
|
|
</p>
|
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="bg-slate-50 rounded-lg p-4">
|
|
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center mb-2">
|
|
<span className="font-bold text-sm">1</span>
|
|
</div>
|
|
<h4 className="font-medium text-slate-900 text-sm">Konfiguration</h4>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Verbinden Sie Ihren Identity Provider (Azure AD, Okta, Keycloak, etc.) ueber OIDC.
|
|
</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-4">
|
|
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center mb-2">
|
|
<span className="font-bold text-sm">2</span>
|
|
</div>
|
|
<h4 className="font-medium text-slate-900 text-sm">Authentifizierung</h4>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Benutzer werden zum Identity Provider weitergeleitet und melden sich dort an.
|
|
</p>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-4">
|
|
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center mb-2">
|
|
<span className="font-bold text-sm">3</span>
|
|
</div>
|
|
<h4 className="font-medium text-slate-900 text-sm">Provisionierung</h4>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Benutzerkonten werden automatisch angelegt und Rollen ueber Gruppen-Mappings zugewiesen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider Setup Guides */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
|
<Globe className="w-5 h-5 text-purple-600" />
|
|
Einrichtungsanleitungen
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{providers.map(provider => (
|
|
<div key={provider.id} className="border border-slate-200 rounded-lg">
|
|
<button
|
|
onClick={() =>
|
|
setExpandedProvider(expandedProvider === provider.id ? null : provider.id)
|
|
}
|
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 transition-colors rounded-lg"
|
|
>
|
|
<span className="font-medium text-slate-900 text-sm">{provider.name}</span>
|
|
{expandedProvider === provider.id ? (
|
|
<ChevronUp className="w-4 h-4 text-slate-400" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-slate-400" />
|
|
)}
|
|
</button>
|
|
{expandedProvider === provider.id && (
|
|
<div className="px-4 pb-4">
|
|
<ol className="space-y-2">
|
|
{provider.steps.map((step, idx) => (
|
|
<li key={idx} className="flex gap-3 text-sm text-slate-600">
|
|
<span className="w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 mt-0.5">
|
|
{idx + 1}
|
|
</span>
|
|
<span>{step}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Security Note */}
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Shield className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<h4 className="font-medium text-purple-900 text-sm">Sicherheitshinweis</h4>
|
|
<p className="text-sm text-purple-700 mt-1">
|
|
Client Secrets werden verschluesselt gespeichert und sind nach der Eingabe nicht mehr einsehbar.
|
|
Aktivieren Sie Auto-Provisioning nur, wenn Sie sicherstellen koennen, dass nur autorisierte
|
|
Benutzer Zugang zum Identity Provider haben. Verwenden Sie Rollen-Mappings, um die
|
|
Berechtigungen Ihrer Benutzer granular zu steuern.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function SSOPage() {
|
|
// State
|
|
const [activeTab, setActiveTab] = useState<TabId>('configs')
|
|
const [configs, setConfigs] = useState<SSOConfig[]>([])
|
|
const [users, setUsers] = useState<SSOUser[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [usersLoading, setUsersLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Modal state
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [editingConfig, setEditingConfig] = useState<SSOConfig | null>(null)
|
|
const [deletingConfig, setDeletingConfig] = useState<SSOConfig | null>(null)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data Loading
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const loadConfigs = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const data = await apiFetch<{ configs: SSOConfig[] }>('/configs')
|
|
setConfigs(data.configs || [])
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden der SSO-Konfigurationen')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
setUsersLoading(true)
|
|
try {
|
|
const data = await apiFetch<{ users: SSOUser[] }>('/users')
|
|
setUsers(data.users || [])
|
|
} catch {
|
|
// Users endpoint might not be available yet - fail silently
|
|
setUsers([])
|
|
} finally {
|
|
setUsersLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadConfigs()
|
|
loadUsers()
|
|
}, [loadConfigs, loadUsers])
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const handleSaveConfig = async (formData: SSOFormData) => {
|
|
const roleMapping: Record<string, string> = {}
|
|
for (const m of formData.role_mappings) {
|
|
if (m.sso_group.trim()) {
|
|
roleMapping[m.sso_group.trim()] = m.internal_role
|
|
}
|
|
}
|
|
const payload: Record<string, unknown> = {
|
|
provider_type: formData.provider_type,
|
|
name: formData.name,
|
|
oidc_issuer_url: formData.issuer_url,
|
|
oidc_client_id: formData.client_id,
|
|
oidc_redirect_uri: formData.redirect_uri,
|
|
oidc_scopes: formData.scopes.split(',').map(s => s.trim()).filter(Boolean),
|
|
role_mapping: roleMapping,
|
|
auto_provision: formData.auto_provision,
|
|
}
|
|
if (formData.client_secret) {
|
|
payload.oidc_client_secret = formData.client_secret
|
|
}
|
|
|
|
if (editingConfig) {
|
|
await apiFetch(`/configs/${editingConfig.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
})
|
|
} else {
|
|
payload.enabled = false
|
|
payload.oidc_client_secret = formData.client_secret
|
|
await apiFetch('/configs', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
})
|
|
}
|
|
|
|
await loadConfigs()
|
|
}
|
|
|
|
const handleDeleteConfig = async () => {
|
|
if (!deletingConfig) return
|
|
await apiFetch(`/configs/${deletingConfig.id}`, { method: 'DELETE' })
|
|
setDeletingConfig(null)
|
|
await loadConfigs()
|
|
await loadUsers()
|
|
}
|
|
|
|
const handleToggleConfig = async (config: SSOConfig) => {
|
|
try {
|
|
await apiFetch(`/configs/${config.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ enabled: !config.enabled }),
|
|
})
|
|
await loadConfigs()
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Aendern des Status')
|
|
}
|
|
}
|
|
|
|
const openCreateForm = () => {
|
|
setEditingConfig(null)
|
|
setShowForm(true)
|
|
}
|
|
|
|
const openEditForm = (config: SSOConfig) => {
|
|
setEditingConfig(config)
|
|
setShowForm(true)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const tabs: { id: TabId; label: string; icon: React.ReactNode; count?: number }[] = [
|
|
{ id: 'configs', label: 'Konfigurationen', icon: <Key className="w-4 h-4" />, count: configs.length },
|
|
{ id: 'users', label: 'SSO-Benutzer', icon: <Users className="w-4 h-4" />, count: users.length },
|
|
{ id: 'info', label: 'Anleitung', icon: <Info className="w-4 h-4" /> },
|
|
]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render
|
|
// ---------------------------------------------------------------------------
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center">
|
|
<Shield className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Single Sign-On (SSO)</h1>
|
|
<p className="text-sm text-slate-500">
|
|
Konfigurieren Sie OIDC-Authentifizierung fuer Ihr Unternehmen
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={openCreateForm}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Neue SSO-Konfiguration
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
|
<div className="text-sm text-slate-500">Konfigurationen</div>
|
|
<div className="text-2xl font-bold text-slate-900 mt-1">{configs.length}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
|
<div className="text-sm text-slate-500">Aktive Provider</div>
|
|
<div className="text-2xl font-bold text-green-600 mt-1">
|
|
{configs.filter(c => c.enabled).length}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
|
<div className="text-sm text-slate-500">SSO-Benutzer</div>
|
|
<div className="text-2xl font-bold text-slate-900 mt-1">{users.length}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
|
<div className="text-sm text-slate-500">Aktive Benutzer</div>
|
|
<div className="text-2xl font-bold text-purple-600 mt-1">
|
|
{users.filter(u => u.is_active).length}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="border-b border-slate-200">
|
|
<nav className="flex gap-1 -mb-px" aria-label="SSO Tabs">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`
|
|
px-4 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2
|
|
${activeTab === tab.id
|
|
? 'border-purple-600 text-purple-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
|
}
|
|
`}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
{tab.count !== undefined && tab.count > 0 && (
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
|
activeTab === tab.id
|
|
? 'bg-purple-100 text-purple-600'
|
|
: 'bg-slate-100 text-slate-500'
|
|
}`}>
|
|
{tab.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Error Banner */}
|
|
{error && (
|
|
<div className="p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
<span className="text-sm">{error}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setError(null)}
|
|
className="text-red-500 hover:text-red-700"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'configs' && (
|
|
<div>
|
|
{loading ? (
|
|
<LoadingSkeleton rows={3} />
|
|
) : configs.length === 0 ? (
|
|
<div className="bg-white rounded-xl border border-slate-200">
|
|
<EmptyState
|
|
icon={<Key className="w-8 h-8 text-slate-400" />}
|
|
title="Keine SSO-Konfigurationen"
|
|
description="Erstellen Sie Ihre erste SSO-Konfiguration, um Ihren Benutzern die Anmeldung ueber Ihren Identity Provider zu ermoeglichen."
|
|
action={
|
|
<button
|
|
onClick={openCreateForm}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Erste Konfiguration erstellen
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{configs.map(config => (
|
|
<SSOConfigCard
|
|
key={config.id}
|
|
config={config}
|
|
onEdit={() => openEditForm(config)}
|
|
onDelete={() => setDeletingConfig(config)}
|
|
onToggle={() => handleToggleConfig(config)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'users' && (
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900">SSO-Benutzer</h2>
|
|
<p className="text-sm text-slate-500 mt-1">
|
|
Uebersicht aller ueber SSO provisionierten Benutzer
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={loadUsers}
|
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-600 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Aktualisieren
|
|
</button>
|
|
</div>
|
|
<SSOUsersTable users={users} loading={usersLoading} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'info' && (
|
|
<SSOInfoSection />
|
|
)}
|
|
|
|
{/* Modals */}
|
|
<SSOConfigFormModal
|
|
isOpen={showForm}
|
|
editConfig={editingConfig}
|
|
onClose={() => {
|
|
setShowForm(false)
|
|
setEditingConfig(null)
|
|
}}
|
|
onSave={handleSaveConfig}
|
|
/>
|
|
|
|
<DeleteConfirmModal
|
|
config={deletingConfig}
|
|
onClose={() => setDeletingConfig(null)}
|
|
onConfirm={handleDeleteConfig}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|