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>
This commit is contained in:
359
admin-compliance/app/sdk/sso/_components/SSOConfigFormModal.tsx
Normal file
359
admin-compliance/app/sdk/sso/_components/SSOConfigFormModal.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { RoleMapping, SSOConfig, SSOFormData } from '../_types'
|
||||
import { AVAILABLE_ROLES, EMPTY_FORM } from './constants'
|
||||
import { getDefaultRedirectUri } from './helpers'
|
||||
import { CopyButton } from './shared'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user