diff --git a/apps/web/src/app/(portal)/admin/ldap/page.tsx b/apps/web/src/app/(portal)/admin/ldap/page.tsx new file mode 100644 index 0000000..d930f99 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/ldap/page.tsx @@ -0,0 +1,564 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface FieldMapping { + id: string; + ldapField: string; + tesseraField: string; + isDefault: boolean; +} + +interface LdapConfig { + id: string; + tenantId: string; + serverUrl: string; + baseDn: string; + bindDn: string; + bindPassword: string; + searchFilter: string; + syncIntervalMin: number; + isActive: boolean; + lastSyncAt: string | null; + fieldMappings: FieldMapping[]; +} + +interface SyncResult { + created: number; + updated: number; + deactivated: number; + errors: string[]; +} + +/** + * LDAP Configuration admin page (D-14, D-16, D-17, D-18). + * Only visible to ADMIN and SUPER_ADMIN roles. + */ +export default function AdminLdapPage() { + const t = useTranslations('admin.ldap'); + const tCommon = useTranslations('common'); + const currentUser = useAuthStore((s) => s.user); + + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testResult, setTestResult] = useState<{ + success: boolean; + error?: string; + } | null>(null); + const [syncResult, setSyncResult] = useState(null); + const [syncing, setSyncing] = useState(false); + + // Form state for connection settings + const [formData, setFormData] = useState({ + serverUrl: '', + baseDn: '', + bindDn: '', + bindPassword: '', + searchFilter: '(objectClass=person)', + syncIntervalMin: 60, + isActive: true, + }); + + // New mapping form + const [newMapping, setNewMapping] = useState({ ldapField: '', tesseraField: '' }); + const [showMappingForm, setShowMappingForm] = useState(false); + + const hasAccess = + currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + const fetchConfig = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/ldap/config`, { + credentials: 'include', + }); + if (res.ok) { + const data = await res.json(); + if (data) { + setConfig(data); + setFormData({ + serverUrl: data.serverUrl || '', + baseDn: data.baseDn || '', + bindDn: data.bindDn || '', + bindPassword: '', + searchFilter: data.searchFilter || '(objectClass=person)', + syncIntervalMin: data.syncIntervalMin ?? 60, + isActive: data.isActive ?? true, + }); + } + } + } catch { + // silently fail + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (hasAccess) { + fetchConfig(); + } else { + setLoading(false); + } + }, [hasAccess, fetchConfig]); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setTestResult(null); + + try { + const method = config ? 'PATCH' : 'POST'; + const body: Record = { ...formData }; + + // Don't send empty password on update (keeps existing) + if (config && !formData.bindPassword) { + delete body.bindPassword; + } + + const res = await fetch(`${API_URL}/ldap/config`, { + method, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body), + }); + + if (res.ok) { + await fetchConfig(); + } + } catch { + // silently fail + } finally { + setSaving(false); + } + }; + + const handleTestConnection = async () => { + setTestResult(null); + try { + const res = await fetch(`${API_URL}/ldap/test-connection`, { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + const data = await res.json(); + setTestResult(data); + } + } catch { + setTestResult({ success: false, error: 'Network error' }); + } + }; + + const handleSync = async () => { + setSyncing(true); + setSyncResult(null); + try { + const res = await fetch(`${API_URL}/ldap/sync`, { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + const data = await res.json(); + setSyncResult(data); + await fetchConfig(); + } + } catch { + setSyncResult({ created: 0, updated: 0, deactivated: 0, errors: ['Network error'] }); + } finally { + setSyncing(false); + } + }; + + const handleAddMapping = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMapping.ldapField || !newMapping.tesseraField) return; + + try { + const res = await fetch(`${API_URL}/ldap/config/mappings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(newMapping), + }); + if (res.ok) { + setNewMapping({ ldapField: '', tesseraField: '' }); + setShowMappingForm(false); + await fetchConfig(); + } + } catch { + // silently fail + } + }; + + const handleRemoveMapping = async (mappingId: string) => { + try { + const res = await fetch(`${API_URL}/ldap/config/mappings/${mappingId}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + await fetchConfig(); + } + } catch { + // silently fail + } + }; + + if (!hasAccess) { + return ( +
+

{tCommon('accessDenied')}

+
+ ); + } + + if (loading) { + return ( +
+

{tCommon('loading')}

+
+ ); + } + + return ( +
+

{t('title')}

+ + {/* Section 1: Connection Settings */} +
+

+ {t('connectionTitle')} +

+
+
+
+ + setFormData({ ...formData, serverUrl: e.target.value })} + placeholder="ldap://ldap.example.com" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + required + /> +
+
+ + setFormData({ ...formData, baseDn: e.target.value })} + placeholder="dc=example,dc=com" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + required + /> +
+
+ + setFormData({ ...formData, bindDn: e.target.value })} + placeholder="cn=admin,dc=example,dc=com" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + required + /> +
+
+ + setFormData({ ...formData, bindPassword: e.target.value })} + placeholder={config ? '********' : ''} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + required={!config} + /> +
+
+
+ + setFormData({ ...formData, searchFilter: e.target.value })} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> +
+ +
+ + {config && ( + + )} +
+ + {testResult && ( +
+ {testResult.success ? t('testSuccess') : `${t('testFailed')}: ${testResult.error}`} +
+ )} +
+
+ + {/* Section 2: Field Mapping (D-16, D-17) */} + {config && ( +
+
+

+ {t('fieldMapping.title')} +

+ +
+ +
+ + + + + + + + + + + {config.fieldMappings.map((mapping) => ( + + + + + + + ))} + +
+ {t('fieldMapping.ldapField')} + + {t('fieldMapping.tesseraField')} + + {t('fieldMapping.default')} + + {tCommon('actions')} +
+ {mapping.ldapField} + + {mapping.tesseraField} + + {mapping.isDefault && ( + + + + + )} + + {!mapping.isDefault && ( + + )} +
+
+ + {/* Add mapping form */} + {showMappingForm && ( +
+
+ + setNewMapping({ ...newMapping, ldapField: e.target.value })} + className="flex h-9 w-40 rounded-md border border-input bg-background px-3 py-1 text-sm" + required + /> +
+
+ + setNewMapping({ ...newMapping, tesseraField: e.target.value })} + className="flex h-9 w-40 rounded-md border border-input bg-background px-3 py-1 text-sm" + required + /> +
+ + +
+ )} +
+ )} + + {/* Section 3: Sync Settings (D-14) */} + {config && ( +
+

+ {t('sync.title')} +

+ +
+ {/* Sync interval */} +
+
+ +
+ + setFormData({ ...formData, syncIntervalMin: parseInt(e.target.value, 10) || 0 }) + } + className="flex h-10 w-24 rounded-md border border-input bg-background px-3 py-2 text-sm" + /> + min + {formData.syncIntervalMin === 0 && ( + + ({t('sync.intervalDisabled')}) + + )} +
+
+
+ + {/* Enable/Disable toggle */} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index e1a1aa0..ce4b156 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -171,6 +171,34 @@ export function Sidebar() { )} + + {/* LDAP link -- ADMIN and SUPER_ADMIN (D-18) */} +
  • + + + + + + + {!isCollapsed && ( + {t('ldap')} + )} + +
  • )} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 3d24a2f..b65314a 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -87,7 +87,8 @@ }, "admin": "Verwaltung", "users": "Benutzer", - "tenants": "Mandanten" + "tenants": "Mandanten", + "ldap": "LDAP" }, "dashboard": { "title": "Dashboard",