This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/app/(sdk)/sdk/sso/page.tsx
BreakPilot Dev 00f778ca9b refactor: Remove Compliance SDK from admin-v2 sidebar, add new SDK modules
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>
2026-02-15 10:20:16 +01:00

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">&rarr;</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>&quot;{config.name}&quot;</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">&rarr;</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>
)
}