Extract types, constants, helpers, and UI pieces (shared LoadingSkeleton/ EmptyState/StatusBadge/CopyButton, SSOConfigFormModal, DeleteConfirmModal, ConnectionTestPanel, SSOConfigCard, SSOUsersTable, SSOInfoSection) into _components/ and _types.ts to bring page.tsx from 1482 LOC to 339 LOC (under the 500 hard cap). Behavior preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
AlertTriangle,
|
|
Info,
|
|
Key,
|
|
Plus,
|
|
RefreshCw,
|
|
Shield,
|
|
Users,
|
|
X,
|
|
} from 'lucide-react'
|
|
|
|
import type { SSOConfig, SSOFormData, SSOUser, TabId } from './_types'
|
|
import { apiFetch } from './_components/helpers'
|
|
import { EmptyState, LoadingSkeleton } from './_components/shared'
|
|
import { SSOConfigFormModal } from './_components/SSOConfigFormModal'
|
|
import { DeleteConfirmModal } from './_components/DeleteConfirmModal'
|
|
import { SSOConfigCard } from './_components/SSOConfigCard'
|
|
import { SSOUsersTable } from './_components/SSOUsersTable'
|
|
import { SSOInfoSection } from './_components/SSOInfoSection'
|
|
|
|
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>
|
|
)
|
|
}
|