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:
117
admin-compliance/app/sdk/sso/_components/ConnectionTestPanel.tsx
Normal file
117
admin-compliance/app/sdk/sso/_components/ConnectionTestPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
TestTube,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { ConnectionTestResult, SSOConfig } from '../_types'
|
||||
import { API_BASE } from './constants'
|
||||
import { apiFetch, getTenantId } from './helpers'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import type { SSOConfig } from '../_types'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
158
admin-compliance/app/sdk/sso/_components/SSOConfigCard.tsx
Normal file
158
admin-compliance/app/sdk/sso/_components/SSOConfigCard.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Key,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { SSOConfig } from '../_types'
|
||||
import { AVAILABLE_ROLES } from './constants'
|
||||
import { formatDate } from './helpers'
|
||||
import { CopyButton, StatusBadge } from './shared'
|
||||
import { ConnectionTestPanel } from './ConnectionTestPanel'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
160
admin-compliance/app/sdk/sso/_components/SSOInfoSection.tsx
Normal file
160
admin-compliance/app/sdk/sso/_components/SSOInfoSection.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Globe, Info, Shield } from 'lucide-react'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
87
admin-compliance/app/sdk/sso/_components/SSOUsersTable.tsx
Normal file
87
admin-compliance/app/sdk/sso/_components/SSOUsersTable.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Users, XCircle } from 'lucide-react'
|
||||
import type { SSOUser } from '../_types'
|
||||
import { formatDate } from './helpers'
|
||||
import { EmptyState, LoadingSkeleton } from './shared'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
28
admin-compliance/app/sdk/sso/_components/constants.ts
Normal file
28
admin-compliance/app/sdk/sso/_components/constants.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SSOFormData } from '../_types'
|
||||
|
||||
export const FALLBACK_TENANT_ID = '00000000-0000-0000-0000-000000000001'
|
||||
export const FALLBACK_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||
export const API_BASE = '/api/sdk/v1/sso'
|
||||
|
||||
export const DEFAULT_SCOPES = 'openid,email,profile'
|
||||
|
||||
export 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' },
|
||||
]
|
||||
|
||||
export 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: [],
|
||||
}
|
||||
53
admin-compliance/app/sdk/sso/_components/helpers.ts
Normal file
53
admin-compliance/app/sdk/sso/_components/helpers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { API_BASE, FALLBACK_TENANT_ID, FALLBACK_USER_ID } from './constants'
|
||||
|
||||
export function getTenantId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sdk-tenant-id') || FALLBACK_TENANT_ID
|
||||
}
|
||||
return FALLBACK_TENANT_ID
|
||||
}
|
||||
|
||||
export function getUserId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sdk-user-id') || FALLBACK_USER_ID
|
||||
}
|
||||
return FALLBACK_USER_ID
|
||||
}
|
||||
|
||||
export function getDefaultRedirectUri(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${window.location.origin}/api/sdk/v1/sso/oidc/callback`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export 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',
|
||||
})
|
||||
}
|
||||
|
||||
export 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()
|
||||
}
|
||||
71
admin-compliance/app/sdk/sso/_components/shared.tsx
Normal file
71
admin-compliance/app/sdk/sso/_components/shared.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2, Copy, XCircle } from 'lucide-react'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
62
admin-compliance/app/sdk/sso/_types.ts
Normal file
62
admin-compliance/app/sdk/sso/_types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface RoleMapping {
|
||||
sso_group: string
|
||||
internal_role: string
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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[]
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
details?: {
|
||||
issuer_reachable: boolean
|
||||
jwks_available: boolean
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TabId = 'configs' | 'users' | 'info'
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user