From 2fb6b98bc5d024ce973a2c63eb1b804e1c4b07f9 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:53:08 +0200 Subject: [PATCH] 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) --- .../sso/_components/ConnectionTestPanel.tsx | 117 ++ .../sso/_components/DeleteConfirmModal.tsx | 65 + .../app/sdk/sso/_components/SSOConfigCard.tsx | 158 +++ .../sso/_components/SSOConfigFormModal.tsx | 359 +++++ .../sdk/sso/_components/SSOInfoSection.tsx | 160 +++ .../app/sdk/sso/_components/SSOUsersTable.tsx | 87 ++ .../app/sdk/sso/_components/constants.ts | 28 + .../app/sdk/sso/_components/helpers.ts | 53 + .../app/sdk/sso/_components/shared.tsx | 71 + admin-compliance/app/sdk/sso/_types.ts | 62 + admin-compliance/app/sdk/sso/page.tsx | 1171 +---------------- 11 files changed, 1174 insertions(+), 1157 deletions(-) create mode 100644 admin-compliance/app/sdk/sso/_components/ConnectionTestPanel.tsx create mode 100644 admin-compliance/app/sdk/sso/_components/DeleteConfirmModal.tsx create mode 100644 admin-compliance/app/sdk/sso/_components/SSOConfigCard.tsx create mode 100644 admin-compliance/app/sdk/sso/_components/SSOConfigFormModal.tsx create mode 100644 admin-compliance/app/sdk/sso/_components/SSOInfoSection.tsx create mode 100644 admin-compliance/app/sdk/sso/_components/SSOUsersTable.tsx create mode 100644 admin-compliance/app/sdk/sso/_components/constants.ts create mode 100644 admin-compliance/app/sdk/sso/_components/helpers.ts create mode 100644 admin-compliance/app/sdk/sso/_components/shared.tsx create mode 100644 admin-compliance/app/sdk/sso/_types.ts diff --git a/admin-compliance/app/sdk/sso/_components/ConnectionTestPanel.tsx b/admin-compliance/app/sdk/sso/_components/ConnectionTestPanel.tsx new file mode 100644 index 0000000..3128854 --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/ConnectionTestPanel.tsx @@ -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(null) + + const runTest = async () => { + setTesting(true) + setResult(null) + try { + const data = await apiFetch(`/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 ( +
+
+

+ + Verbindungstest +

+
+ + +
+
+ + {result && ( +
+
+ {result.success ? ( + + ) : ( + + )} + {result.message} +
+ {result.details && ( +
+
+ {result.details.issuer_reachable ? ( + + ) : ( + + )} + Issuer erreichbar +
+
+ {result.details.jwks_available ? ( + + ) : ( + + )} + JWKS verfuegbar +
+ {result.details.authorization_endpoint && ( +
+ Authorization: {result.details.authorization_endpoint} +
+ )} + {result.details.token_endpoint && ( +
+ Token: {result.details.token_endpoint} +
+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/sso/_components/DeleteConfirmModal.tsx b/admin-compliance/app/sdk/sso/_components/DeleteConfirmModal.tsx new file mode 100644 index 0000000..3a5e0d5 --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/DeleteConfirmModal.tsx @@ -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 +}) { + const [deleting, setDeleting] = useState(false) + + if (!config) return null + + const handleDelete = async () => { + setDeleting(true) + try { + await onConfirm() + onClose() + } finally { + setDeleting(false) + } + } + + return ( +
+
+
+
+ +
+
+

Konfiguration loeschen?

+

Diese Aktion kann nicht rueckgaengig gemacht werden.

+
+
+

+ Die SSO-Konfiguration "{config.name}" wird unwiderruflich geloescht. + Alle ueber diese Konfiguration provisionierten Benutzer verlieren den SSO-Zugang. +

+
+ + +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/sso/_components/SSOConfigCard.tsx b/admin-compliance/app/sdk/sso/_components/SSOConfigCard.tsx new file mode 100644 index 0000000..156ebda --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/SSOConfigCard.tsx @@ -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 ( +
+ {/* Main Row */} +
+
+
+
+ +
+
+
+

{config.name}

+ + {config.provider_type} + + +
+

{config.oidc_issuer_url}

+
+ Client ID: {(config.oidc_client_id || '').substring(0, 12)}... + Scopes: {(config.oidc_scopes || []).join(', ')} + Erstellt: {formatDate(config.created_at)} +
+
+
+ + {/* Actions */} +
+ + + + +
+
+
+ + {/* Expanded Details */} + {expanded && ( +
+ {/* Config Details Grid */} +
+
+ Redirect URI: +
+ {config.oidc_redirect_uri} + +
+
+
+ Auto-Provisioning: +
+ {config.auto_provision ? 'Aktiviert' : 'Deaktiviert'} +
+
+
+ Standard-Rolle: +
+ {config.default_role_id || 'Standard'} +
+
+
+ Scopes: +
+ {(config.oidc_scopes || []).map(scope => ( + + {scope} + + ))} +
+
+
+ + {/* Role Mappings */} + {config.role_mapping && Object.keys(config.role_mapping).length > 0 && ( +
+

Rollen-Mapping

+
+ {Object.entries(config.role_mapping).map(([group, role]) => ( +
+ + {group} + + + + {AVAILABLE_ROLES.find(r => r.value === role)?.label || role} + +
+ ))} +
+
+ )} + + {/* Connection Test */} + +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/sso/_components/SSOConfigFormModal.tsx b/admin-compliance/app/sdk/sso/_components/SSOConfigFormModal.tsx new file mode 100644 index 0000000..438440f --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/SSOConfigFormModal.tsx @@ -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 +}) { + const [formData, setFormData] = useState(EMPTY_FORM) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(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 ( +
+
+ {/* Header */} +
+

+ {editConfig ? 'SSO-Konfiguration bearbeiten' : 'Neue SSO-Konfiguration'} +

+ +
+ + {/* Form */} +
+ {error && ( +
+ + {error} +
+ )} + + {/* Provider Type */} +
+ + +
+ + {/* Name */} +
+ + 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" + /> +
+ + {/* Issuer URL */} +
+ + 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" + /> +

+ Die Discovery-URL wird automatisch unter /.well-known/openid-configuration abgefragt +

+
+ + {/* Client ID */} +
+ + 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" + /> +
+ + {/* Client Secret */} +
+ +
+ 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" + /> + +
+
+ + {/* Redirect URI */} +
+ +
+ 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" + /> + +
+

+ Diese URI muss im Identity Provider als erlaubte Redirect-URI eingetragen werden +

+
+ + {/* Scopes */} +
+ + 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" + /> +

+ Komma-getrennte Liste von OIDC Scopes +

+
+ + {/* Auto-Provision Toggle */} +
+
+ +

+ Benutzer automatisch beim ersten Login anlegen +

+
+ +
+ + {/* Default Role */} +
+ + +

+ Rolle fuer neu provisionierte Benutzer ohne Rollen-Mapping +

+
+ + {/* Role Mappings */} +
+
+ + +
+ {formData.role_mappings.length === 0 ? ( +

+ Keine Rollen-Mappings konfiguriert. Alle Benutzer erhalten die Standard-Rolle. +

+ ) : ( +
+ {formData.role_mappings.map((mapping, idx) => ( +
+ 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" + /> + + + +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/sso/_components/SSOInfoSection.tsx b/admin-compliance/app/sdk/sso/_components/SSOInfoSection.tsx new file mode 100644 index 0000000..c4cd28c --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/SSOInfoSection.tsx @@ -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(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 ( +
+ {/* How SSO Works */} +
+

+ + Wie funktioniert SSO? +

+
+

+ 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. +

+
+
+
+ 1 +
+

Konfiguration

+

+ Verbinden Sie Ihren Identity Provider (Azure AD, Okta, Keycloak, etc.) ueber OIDC. +

+
+
+
+ 2 +
+

Authentifizierung

+

+ Benutzer werden zum Identity Provider weitergeleitet und melden sich dort an. +

+
+
+
+ 3 +
+

Provisionierung

+

+ Benutzerkonten werden automatisch angelegt und Rollen ueber Gruppen-Mappings zugewiesen. +

+
+
+
+
+ + {/* Provider Setup Guides */} +
+

+ + Einrichtungsanleitungen +

+
+ {providers.map(provider => ( +
+ + {expandedProvider === provider.id && ( +
+
    + {provider.steps.map((step, idx) => ( +
  1. + + {idx + 1} + + {step} +
  2. + ))} +
+
+ )} +
+ ))} +
+
+ + {/* Security Note */} +
+
+ +
+

Sicherheitshinweis

+

+ 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. +

+
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/sso/_components/SSOUsersTable.tsx b/admin-compliance/app/sdk/sso/_components/SSOUsersTable.tsx new file mode 100644 index 0000000..cfa9f1a --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/SSOUsersTable.tsx @@ -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 + } + + if (users.length === 0) { + return ( + } + title="Keine SSO-Benutzer" + description="Es wurden noch keine Benutzer ueber SSO provisioniert. Benutzer erscheinen hier nach dem ersten Login." + /> + ) + } + + return ( +
+ + + + + + + + + + + + + {users.map(user => ( + + + + + + + + + ))} + +
NameE-MailExterne IDGruppenLetzter LoginStatus
+
{user.display_name}
+
{user.email} + {user.external_id} + +
+ {user.groups.length > 0 ? ( + user.groups.map(group => ( + + {group} + + )) + ) : ( + - + )} +
+
+ {formatDate(user.last_login)} + + {user.is_active ? ( + + + Aktiv + + ) : ( + + + Inaktiv + + )} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/sso/_components/constants.ts b/admin-compliance/app/sdk/sso/_components/constants.ts new file mode 100644 index 0000000..3f14d4b --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/constants.ts @@ -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: [], +} diff --git a/admin-compliance/app/sdk/sso/_components/helpers.ts b/admin-compliance/app/sdk/sso/_components/helpers.ts new file mode 100644 index 0000000..6531020 --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/helpers.ts @@ -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( + endpoint: string, + options: RequestInit = {} +): Promise { + 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() +} diff --git a/admin-compliance/app/sdk/sso/_components/shared.tsx b/admin-compliance/app/sdk/sso/_components/shared.tsx new file mode 100644 index 0000000..a50e054 --- /dev/null +++ b/admin-compliance/app/sdk/sso/_components/shared.tsx @@ -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 ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ) +} + +export function EmptyState({ + icon, + title, + description, + action, +}: { + icon: React.ReactNode + title: string + description: string + action?: React.ReactNode +}) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+ {action &&
{action}
} +
+ ) +} + +export function StatusBadge({ enabled }: { enabled: boolean }) { + return enabled ? ( + + + Aktiv + + ) : ( + + + Deaktiviert + + ) +} + +export function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} diff --git a/admin-compliance/app/sdk/sso/_types.ts b/admin-compliance/app/sdk/sso/_types.ts new file mode 100644 index 0000000..7b414fa --- /dev/null +++ b/admin-compliance/app/sdk/sso/_types.ts @@ -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 + 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' diff --git a/admin-compliance/app/sdk/sso/page.tsx b/admin-compliance/app/sdk/sso/page.tsx index 5705c39..0929899 100644 --- a/admin-compliance/app/sdk/sso/page.tsx +++ b/admin-compliance/app/sdk/sso/page.tsx @@ -1,1168 +1,25 @@ 'use client' -import React, { useState, useEffect, useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { - Shield, - Plus, - Pencil, - Trash2, - ExternalLink, - Copy, - CheckCircle2, - XCircle, - Loader2, - Users, - Key, - Globe, + AlertTriangle, Info, - ChevronDown, - ChevronUp, - TestTube, + Key, + Plus, RefreshCw, + Shield, + Users, 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 - 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( - endpoint: string, - options: RequestInit = {} -): Promise { - 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 ( -
- {Array.from({ length: rows }).map((_, i) => ( -
- ))} -
- ) -} - -function EmptyState({ - icon, - title, - description, - action, -}: { - icon: React.ReactNode - title: string - description: string - action?: React.ReactNode -}) { - return ( -
-
- {icon} -
-

{title}

-

{description}

- {action &&
{action}
} -
- ) -} - -function StatusBadge({ enabled }: { enabled: boolean }) { - return enabled ? ( - - - Aktiv - - ) : ( - - - Deaktiviert - - ) -} - -function CopyButton({ value }: { value: string }) { - const [copied, setCopied] = useState(false) - - const handleCopy = () => { - navigator.clipboard.writeText(value) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - return ( - - ) -} - -// ============================================================================= -// SSO CONFIG FORM MODAL -// ============================================================================= - -function SSOConfigFormModal({ - isOpen, - editConfig, - onClose, - onSave, -}: { - isOpen: boolean - editConfig: SSOConfig | null - onClose: () => void - onSave: (data: SSOFormData) => Promise -}) { - const [formData, setFormData] = useState(EMPTY_FORM) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(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 ( -
-
- {/* Header */} -
-

- {editConfig ? 'SSO-Konfiguration bearbeiten' : 'Neue SSO-Konfiguration'} -

- -
- - {/* Form */} -
- {error && ( -
- - {error} -
- )} - - {/* Provider Type */} -
- - -
- - {/* Name */} -
- - 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" - /> -
- - {/* Issuer URL */} -
- - 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" - /> -

- Die Discovery-URL wird automatisch unter /.well-known/openid-configuration abgefragt -

-
- - {/* Client ID */} -
- - 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" - /> -
- - {/* Client Secret */} -
- -
- 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" - /> - -
-
- - {/* Redirect URI */} -
- -
- 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" - /> - -
-

- Diese URI muss im Identity Provider als erlaubte Redirect-URI eingetragen werden -

-
- - {/* Scopes */} -
- - 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" - /> -

- Komma-getrennte Liste von OIDC Scopes -

-
- - {/* Auto-Provision Toggle */} -
-
- -

- Benutzer automatisch beim ersten Login anlegen -

-
- -
- - {/* Default Role */} -
- - -

- Rolle fuer neu provisionierte Benutzer ohne Rollen-Mapping -

-
- - {/* Role Mappings */} -
-
- - -
- {formData.role_mappings.length === 0 ? ( -

- Keine Rollen-Mappings konfiguriert. Alle Benutzer erhalten die Standard-Rolle. -

- ) : ( -
- {formData.role_mappings.map((mapping, idx) => ( -
- 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" - /> - - - -
- ))} -
- )} -
- - {/* Footer */} -
- - -
-
-
-
- ) -} - -// ============================================================================= -// DELETE CONFIRMATION MODAL -// ============================================================================= - -function DeleteConfirmModal({ - config, - onClose, - onConfirm, -}: { - config: SSOConfig | null - onClose: () => void - onConfirm: () => Promise -}) { - const [deleting, setDeleting] = useState(false) - - if (!config) return null - - const handleDelete = async () => { - setDeleting(true) - try { - await onConfirm() - onClose() - } finally { - setDeleting(false) - } - } - - return ( -
-
-
-
- -
-
-

Konfiguration loeschen?

-

Diese Aktion kann nicht rueckgaengig gemacht werden.

-
-
-

- Die SSO-Konfiguration "{config.name}" wird unwiderruflich geloescht. - Alle ueber diese Konfiguration provisionierten Benutzer verlieren den SSO-Zugang. -

-
- - -
-
-
- ) -} - -// ============================================================================= -// CONNECTION TEST PANEL -// ============================================================================= - -function ConnectionTestPanel({ config }: { config: SSOConfig }) { - const [testing, setTesting] = useState(false) - const [result, setResult] = useState(null) - - const runTest = async () => { - setTesting(true) - setResult(null) - try { - const data = await apiFetch(`/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 ( -
-
-

- - Verbindungstest -

-
- - -
-
- - {result && ( -
-
- {result.success ? ( - - ) : ( - - )} - {result.message} -
- {result.details && ( -
-
- {result.details.issuer_reachable ? ( - - ) : ( - - )} - Issuer erreichbar -
-
- {result.details.jwks_available ? ( - - ) : ( - - )} - JWKS verfuegbar -
- {result.details.authorization_endpoint && ( -
- Authorization: {result.details.authorization_endpoint} -
- )} - {result.details.token_endpoint && ( -
- Token: {result.details.token_endpoint} -
- )} -
- )} -
- )} -
- ) -} - -// ============================================================================= -// SSO CONFIG CARD -// ============================================================================= - -function SSOConfigCard({ - config, - onEdit, - onDelete, - onToggle, -}: { - config: SSOConfig - onEdit: () => void - onDelete: () => void - onToggle: () => void -}) { - const [expanded, setExpanded] = useState(false) - - return ( -
- {/* Main Row */} -
-
-
-
- -
-
-
-

{config.name}

- - {config.provider_type} - - -
-

{config.oidc_issuer_url}

-
- Client ID: {(config.oidc_client_id || '').substring(0, 12)}... - Scopes: {(config.oidc_scopes || []).join(', ')} - Erstellt: {formatDate(config.created_at)} -
-
-
- - {/* Actions */} -
- - - - -
-
-
- - {/* Expanded Details */} - {expanded && ( -
- {/* Config Details Grid */} -
-
- Redirect URI: -
- {config.oidc_redirect_uri} - -
-
-
- Auto-Provisioning: -
- {config.auto_provision ? 'Aktiviert' : 'Deaktiviert'} -
-
-
- Standard-Rolle: -
- {config.default_role_id || 'Standard'} -
-
-
- Scopes: -
- {(config.oidc_scopes || []).map(scope => ( - - {scope} - - ))} -
-
-
- - {/* Role Mappings */} - {config.role_mapping && Object.keys(config.role_mapping).length > 0 && ( -
-

Rollen-Mapping

-
- {Object.entries(config.role_mapping).map(([group, role]) => ( -
- - {group} - - - - {AVAILABLE_ROLES.find(r => r.value === role)?.label || role} - -
- ))} -
-
- )} - - {/* Connection Test */} - -
- )} -
- ) -} - -// ============================================================================= -// SSO USERS TABLE -// ============================================================================= - -function SSOUsersTable({ - users, - loading, -}: { - users: SSOUser[] - loading: boolean -}) { - if (loading) { - return - } - - if (users.length === 0) { - return ( - } - title="Keine SSO-Benutzer" - description="Es wurden noch keine Benutzer ueber SSO provisioniert. Benutzer erscheinen hier nach dem ersten Login." - /> - ) - } - - return ( -
- - - - - - - - - - - - - {users.map(user => ( - - - - - - - - - ))} - -
NameE-MailExterne IDGruppenLetzter LoginStatus
-
{user.display_name}
-
{user.email} - {user.external_id} - -
- {user.groups.length > 0 ? ( - user.groups.map(group => ( - - {group} - - )) - ) : ( - - - )} -
-
- {formatDate(user.last_login)} - - {user.is_active ? ( - - - Aktiv - - ) : ( - - - Inaktiv - - )} -
-
- ) -} - -// ============================================================================= -// INFO SECTION -// ============================================================================= - -function SSOInfoSection() { - const [expandedProvider, setExpandedProvider] = useState(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 ( -
- {/* How SSO Works */} -
-

- - Wie funktioniert SSO? -

-
-

- 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. -

-
-
-
- 1 -
-

Konfiguration

-

- Verbinden Sie Ihren Identity Provider (Azure AD, Okta, Keycloak, etc.) ueber OIDC. -

-
-
-
- 2 -
-

Authentifizierung

-

- Benutzer werden zum Identity Provider weitergeleitet und melden sich dort an. -

-
-
-
- 3 -
-

Provisionierung

-

- Benutzerkonten werden automatisch angelegt und Rollen ueber Gruppen-Mappings zugewiesen. -

-
-
-
-
- - {/* Provider Setup Guides */} -
-

- - Einrichtungsanleitungen -

-
- {providers.map(provider => ( -
- - {expandedProvider === provider.id && ( -
-
    - {provider.steps.map((step, idx) => ( -
  1. - - {idx + 1} - - {step} -
  2. - ))} -
-
- )} -
- ))} -
-
- - {/* Security Note */} -
-
- -
-

Sicherheitshinweis

-

- 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. -

-
-
-
-
- ) -} - -// ============================================================================= -// MAIN PAGE -// ============================================================================= +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