Files
breakpilot-compliance/admin-compliance/app/sdk/sso/page.tsx
Sharang Parnerkar 2fb6b98bc5 refactor(admin): split sso page.tsx into colocated components
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>
2026-04-11 22:53:08 +02:00

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>
)
}