'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; groupFilterDns: string[]; lastSyncAt: string | null; fieldMappings: FieldMapping[]; } interface LdapDirectoryEntry { dn: string; name: string; type: 'group' | 'ou'; } 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. // Defaults are pre-filled with the known-good CTL Active Directory // connection values (sourced from the working XWiki LDAP config) so a // brand-new setup only needs the service-account password. fetchConfig() // below overwrites these with the real saved values whenever a config // already exists. const [formData, setFormData] = useState({ serverUrl: 'ldap://balios.ctl.local:3268', baseDn: 'dc=ctl,dc=local', bindDn: '', bindPassword: '', searchFilter: '(&(objectClass=user)(objectCategory=person))', syncIntervalMin: 60, isActive: true, }); // New mapping form const [newMapping, setNewMapping] = useState({ ldapField: '', tesseraField: '' }); const [showMappingForm, setShowMappingForm] = useState(false); // Group/OU import filter (selective sync) const [groupFilterDns, setGroupFilterDns] = useState([]); const [discovered, setDiscovered] = useState(null); const [discovering, setDiscovering] = useState(false); const [manualDn, setManualDn] = useState(''); const [savingFilter, setSavingFilter] = 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, }); setGroupFilterDns(data.groupFilterDns ?? []); } } } 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 } }; const handleDiscoverGroups = async () => { setDiscovering(true); try { const res = await fetch(`${API_URL}/ldap/groups`, { credentials: 'include', }); if (res.ok) { const data = await res.json(); setDiscovered(data); } } catch { // silently fail } finally { setDiscovering(false); } }; const toggleGroupFilterDn = (dn: string) => { setGroupFilterDns((prev) => prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn], ); }; const handleAddManualDn = () => { const dn = manualDn.trim(); if (!dn || groupFilterDns.includes(dn)) return; setGroupFilterDns((prev) => [...prev, dn]); setManualDn(''); }; const handleRemoveGroupFilterDn = (dn: string) => { setGroupFilterDns((prev) => prev.filter((d) => d !== dn)); }; const handleSaveGroupFilter = async () => { setSavingFilter(true); try { const res = await fetch(`${API_URL}/ldap/config`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ groupFilterDns }), }); if (res.ok) { await fetchConfig(); } } catch { // silently fail } finally { setSavingFilter(false); } }; 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="ctl\serviceaccount" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" required />

{t('bindDnHint')}

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 2.5: Group/OU import filter (selective sync) */} {config && (

{t('groupFilter.title')}

{t('groupFilter.description')}

{discovered && discovered.length > 0 && (
{discovered.map((entry) => ( ))}
)} {discovered && discovered.length === 0 && (

{t('groupFilter.noneFound')}

)}
setManualDn(e.target.value)} placeholder="CN=Beispiel,OU=Gruppen,DC=ctl,DC=local" className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm font-mono" />

{t('groupFilter.selected')}

{groupFilterDns.length === 0 ? (

{t('groupFilter.emptyMeansAll')}

) : (
    {groupFilterDns.map((dn) => (
  • {dn}
  • ))}
)}
)} {/* 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 */}
)}
); }