'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; tlsRejectUnauthorized: boolean; groupFilterDns: string[]; userExcludeList: string[]; lastSyncAt: string | null; fieldMappings: FieldMapping[]; } interface LdapDirectoryEntry { dn: string; name: string; type: 'group' | 'ou'; alreadyImported?: boolean; } interface LdapUserSearchResult { dn: string; username: string; displayName: string; email: string; alreadyImported: boolean; } interface UserImportResult { created: number; updated: number; skipped: number; errors: string[]; } interface GroupImportResult { imported: number; skipped: number; nameCollisions: string[]; errors: string[]; } 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: 0, isActive: true, tlsRejectUnauthorized: 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 [discoverSearch, setDiscoverSearch] = useState(''); // Per-user exclude/denylist (individual usernames never imported) const [userExcludeList, setUserExcludeList] = useState([]); const [newExcludeUser, setNewExcludeUser] = useState(''); const [savingExclude, setSavingExclude] = useState(false); // Individual user search & import const [userSearchQuery, setUserSearchQuery] = useState(''); const [userSearchResults, setUserSearchResults] = useState< LdapUserSearchResult[] | null >(null); const [userSearching, setUserSearching] = useState(false); const [selectedUserDns, setSelectedUserDns] = useState([]); const [importingUsers, setImportingUsers] = useState(false); const [userImportResult, setUserImportResult] = useState(null); // AD group import (SC-1/SC-2, D-01/D-02) — own state, own discovery call, // independent of Section 2.5's groupFilterDns picker (different purpose). const [groupImportCandidates, setGroupImportCandidates] = useState< LdapDirectoryEntry[] | null >(null); const [groupImportSearch, setGroupImportSearch] = useState(''); const [discoveringGroupImport, setDiscoveringGroupImport] = useState(false); const [selectedImportDns, setSelectedImportDns] = useState([]); const [importingGroups, setImportingGroups] = useState(false); const [groupImportResult, setGroupImportResult] = useState(null); const [groupImportError, setGroupImportError] = useState( null, ); const filteredGroupImportCandidates = groupImportCandidates?.filter( (entry) => { const q = groupImportSearch.trim().toLowerCase(); if (!q) return true; return ( entry.name.toLowerCase().includes(q) || entry.dn.toLowerCase().includes(q) ); }, ); const filteredDiscovered = discovered?.filter((entry) => { const q = discoverSearch.trim().toLowerCase(); if (!q) return true; return ( entry.name.toLowerCase().includes(q) || entry.dn.toLowerCase().includes(q) ); }); 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, tlsRejectUnauthorized: data.tlsRejectUnauthorized ?? true, }); setGroupFilterDns(data.groupFilterDns ?? []); setUserExcludeList(data.userExcludeList ?? []); } } } 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 { // Send the currently entered values so a connection can be validated // before ever saving a config. bindPassword is omitted when blank so // the backend falls back to the saved config's password (masked // fields never get re-sent once a config already exists). const body: Record = {}; if (formData.serverUrl) body.serverUrl = formData.serverUrl; if (formData.bindDn) body.bindDn = formData.bindDn; if (formData.bindPassword) body.bindPassword = formData.bindPassword; // Always send the current TLS-verification choice so the test reflects it. body.tlsRejectUnauthorized = formData.tlsRejectUnauthorized; const res = await fetch(`${API_URL}/ldap/test-connection`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body), }); const data = await res.json(); if (res.ok) { setTestResult(data); } else { setTestResult({ success: false, error: data.message || 'Test failed' }); } } 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); } }; const handleAddExcludeUser = () => { const name = newExcludeUser.trim().toLowerCase(); if (!name || userExcludeList.includes(name)) return; setUserExcludeList((prev) => [...prev, name]); setNewExcludeUser(''); }; const handleRemoveExcludeUser = (name: string) => { setUserExcludeList((prev) => prev.filter((u) => u !== name)); }; const handleSearchUsers = async () => { const q = userSearchQuery.trim(); if (!q) return; setUserSearching(true); setUserImportResult(null); try { const res = await fetch( `${API_URL}/ldap/users/search?q=${encodeURIComponent(q)}`, { credentials: 'include' }, ); if (res.ok) { const data = await res.json(); setUserSearchResults(data); setSelectedUserDns([]); } } catch { // silently fail } finally { setUserSearching(false); } }; const toggleUserDn = (dn: string) => { setSelectedUserDns((prev) => prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn], ); }; const handleImportUsers = async (dns: string[]) => { if (dns.length === 0) return; setImportingUsers(true); setUserImportResult(null); try { const res = await fetch(`${API_URL}/ldap/users/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ dns }), }); if (res.ok) { const data = await res.json(); setUserImportResult(data); setSelectedUserDns([]); // Re-run the search so alreadyImported flags refresh. await handleSearchUsers(); } } catch { // silently fail } finally { setImportingUsers(false); } }; const handleDiscoverGroupsToImport = async () => { setDiscoveringGroupImport(true); setGroupImportError(null); try { const res = await fetch(`${API_URL}/ldap/groups`, { credentials: 'include', }); if (res.ok) { const data: LdapDirectoryEntry[] = await res.json(); // Only AD groups are importable — OUs are not selectable here. setGroupImportCandidates(data.filter((entry) => entry.type === 'group')); } else { setGroupImportError(t('groupImport.discoverError')); } } catch { setGroupImportError(t('groupImport.discoverError')); } finally { setDiscoveringGroupImport(false); } }; const toggleImportDn = (dn: string) => { setSelectedImportDns((prev) => prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn], ); }; const handleImportGroups = async () => { if (selectedImportDns.length === 0) return; setImportingGroups(true); setGroupImportError(null); try { const res = await fetch(`${API_URL}/ldap/groups/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ dns: selectedImportDns }), }); if (res.ok) { const data: GroupImportResult = await res.json(); setGroupImportResult(data); setSelectedImportDns([]); // Re-run discovery so alreadyImported flags refresh immediately. await handleDiscoverGroupsToImport(); } else { setGroupImportError(t('groupImport.importError')); } } catch { setGroupImportError(t('groupImport.importError')); } finally { setImportingGroups(false); } }; const handleSaveExcludeList = async () => { setSavingExclude(true); try { const res = await fetch(`${API_URL}/ldap/config`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ userExcludeList }), }); if (res.ok) { await fetchConfig(); } } catch { // silently fail } finally { setSavingExclude(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 />