diff --git a/client/src/layouts/dashboard-layout.tsx b/client/src/layouts/dashboard-layout.tsx index 7542400..2c4cff1 100644 --- a/client/src/layouts/dashboard-layout.tsx +++ b/client/src/layouts/dashboard-layout.tsx @@ -278,3 +278,5 @@ export function DashboardLayout({ children, title, description }: DashboardLayou ); } + +export default DashboardLayout; diff --git a/client/src/pages/ldap-query-builder-page.tsx b/client/src/pages/ldap-query-builder-page.tsx index 400e941..73b3b00 100644 --- a/client/src/pages/ldap-query-builder-page.tsx +++ b/client/src/pages/ldap-query-builder-page.tsx @@ -1,970 +1,698 @@ import { useState, useEffect } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; import { DashboardLayout } from "@/layouts/dashboard-layout"; -import { DataTable } from "@/components/ui/data-table"; -import { Button } from "@/components/ui/button"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { Plus, Pencil, Trash2, Save, History, RotateCcw, Play, Filter } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { queryClient, apiRequest } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient } from "@/lib/queryClient"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { format } from "date-fns"; +import { Loader2, Save, Trash, History, ArrowLeftRight, RefreshCw, Play } from "lucide-react"; +import { useAuth } from "@/hooks/use-auth"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; -// Types for LDAP objects -interface LdapAttribute { - id: number; - connectionId: number; - name: string; - displayName: string; - description: string; - type: string; - multiValued: boolean; - objectClass: string; - isIndexed: boolean; -} - -interface LdapFilter { - id: number; - connectionId: number; - name: string; - description: string; - objectClass: string; - filter: any; - ldapFilter: string; - createdAt: string; - createdBy: number; - modifiedAt: string; - modifiedBy: number; - currentVersion: number; - isActive: boolean; -} - -interface LdapFilterRevision { - id: number; - filterId: number; - version: number; - filter: any; - ldapFilter: string; - createdAt: string; - createdBy: number; - comment: string; -} - -interface LdapConnection { +type LdapConnection = { id: number; name: string; server: string; domain: string; - port: number; - useSSL: boolean; - status: string; -} +}; -interface FilterCondition { - attribute: string; - operator: string; - value: string; -} +type LdapFilter = { + id: number; + name: string; + description: string | null; + filter: any; + ldapFilter: string; + connectionId: number; + createdBy: number; + createdAt: string; + modifiedBy: number | null; + modifiedAt: string | null; + isActive: boolean; +}; -interface FilterGroup { - type: "AND" | "OR"; - conditions: (FilterCondition | FilterGroup)[]; -} +type LdapFilterRevision = { + id: number; + filterId: number; + filter: any; + ldapFilter: string; + modifiedBy: number; + modifiedAt: string; +}; -export default function LdapQueryBuilderPage() { +const LdapQueryBuilderPage = () => { + const { user } = useAuth(); const { toast } = useToast(); - const [selectedConnection, setSelectedConnection] = useState(null); - const [selectedObjectClass, setSelectedObjectClass] = useState("user"); - const [filterToEdit, setFilterToEdit] = useState(null); - const [filterToDelete, setFilterToDelete] = useState(null); + const [selectedConnectionId, setSelectedConnectionId] = useState(); + const [selectedFilter, setSelectedFilter] = useState(null); const [filterName, setFilterName] = useState(""); const [filterDescription, setFilterDescription] = useState(""); - const [ldapFilter, setLdapFilter] = useState(""); - const [showHistory, setShowHistory] = useState(false); - const [currentFilter, setCurrentFilter] = useState({ - type: "AND", - conditions: [] - }); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); + const [filterQuery, setFilterQuery] = useState(""); + const [objectClass, setObjectClass] = useState("user"); + const [showRevisionsDialog, setShowRevisionsDialog] = useState(false); + const [showTestResultsDialog, setShowTestResultsDialog] = useState(false); const [testResults, setTestResults] = useState([]); - const [revisionToRevert, setRevisionToRevert] = useState(null); + const [isActive, setIsActive] = useState(true); + const [filterBuilder, setFilterBuilder] = useState({ + operator: "and", + conditions: [{ attribute: "", operator: "equals", value: "" }] + }); - // Get all LDAP connections - const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + // Query to get LDAP connections + const { + data: connections, + isLoading: connectionsLoading + } = useQuery({ queryKey: ["/api/ldap-connections"], + enabled: !!user }); - // Get attributes for the selected object class - const { data: attributes = [], isLoading: isLoadingAttributes } = useQuery({ - queryKey: ["/api/connections", selectedConnection, "ldap-attributes", selectedObjectClass], - queryFn: async () => { - if (!selectedConnection) return []; - const res = await apiRequest("GET", `/api/connections/${selectedConnection}/ldap-attributes?objectClass=${selectedObjectClass}`); - return await res.json(); - }, - enabled: !!selectedConnection, + // Query to get LDAP filters for the selected connection + const { + data: filters, + isLoading: filtersLoading + } = useQuery({ + queryKey: ["/api/connections", selectedConnectionId, "ldap-filters"], + enabled: !!selectedConnectionId, }); - // Get saved filters for the selected connection - const { data: savedFilters = [], isLoading: isLoadingSavedFilters } = useQuery({ - queryKey: ["/api/connections", selectedConnection, "ldap-filters"], - queryFn: async () => { - if (!selectedConnection) return []; - const res = await apiRequest("GET", `/api/connections/${selectedConnection}/ldap-filters`); - return await res.json(); - }, - enabled: !!selectedConnection, + // Query to get LDAP filter revisions + const { + data: revisions, + isLoading: revisionsLoading, + refetch: refetchRevisions + } = useQuery({ + queryKey: ["/api/ldap-filters", selectedFilter?.id, "revisions"], + enabled: !!selectedFilter?.id && showRevisionsDialog, }); - // Get revision history for the selected filter - const { data: filterRevisions = [], isLoading: isLoadingRevisions } = useQuery({ - queryKey: ["/api/ldap-filters", filterToEdit?.id, "revisions"], - queryFn: async () => { - if (!filterToEdit) return []; - const res = await apiRequest("GET", `/api/ldap-filters/${filterToEdit.id}/revisions`); - return await res.json(); - }, - enabled: !!filterToEdit && showHistory, - }); - - // Create new filter + // Mutation to save a new filter const createFilterMutation = useMutation({ - mutationFn: async (filterData: any) => { - const res = await apiRequest("POST", `/api/connections/${selectedConnection}/ldap-filters`, filterData); + mutationFn: async (newFilter: any) => { + if (!selectedConnectionId) throw new Error("No connection selected"); + const res = await apiRequest("POST", `/api/connections/${selectedConnectionId}/ldap-filters`, newFilter); return await res.json(); }, onSuccess: () => { toast({ - title: "Filter created", - description: "The LDAP filter has been successfully created.", + title: "Success", + description: "Filter created successfully", }); - queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); - resetFilterForm(); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnectionId, "ldap-filters"] }); + resetForm(); }, - onError: (error) => { + onError: (error: Error) => { toast({ - title: "Error", - description: `Failed to create filter: ${error.message}`, + title: "Error creating filter", + description: error.message, variant: "destructive", }); - }, + } }); - // Update filter + // Mutation to update an existing filter const updateFilterMutation = useMutation({ - mutationFn: async ({ id, data }: { id: number; data: any }) => { - const res = await apiRequest("PUT", `/api/ldap-filters/${id}`, data); + mutationFn: async (updatedFilter: any) => { + if (!selectedFilter) throw new Error("No filter selected"); + const res = await apiRequest("PUT", `/api/ldap-filters/${selectedFilter.id}`, updatedFilter); return await res.json(); }, onSuccess: () => { toast({ - title: "Filter updated", - description: "The LDAP filter has been successfully updated.", + title: "Success", + description: "Filter updated successfully", }); - queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); - resetFilterForm(); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnectionId, "ldap-filters"] }); + resetForm(); }, - onError: (error) => { + onError: (error: Error) => { toast({ - title: "Error", - description: `Failed to update filter: ${error.message}`, + title: "Error updating filter", + description: error.message, variant: "destructive", }); - }, + } }); - // Delete filter + // Mutation to delete a filter const deleteFilterMutation = useMutation({ - mutationFn: async (id: number) => { - await apiRequest("DELETE", `/api/ldap-filters/${id}`); + mutationFn: async () => { + if (!selectedFilter) throw new Error("No filter selected"); + return await apiRequest("DELETE", `/api/ldap-filters/${selectedFilter.id}`); }, onSuccess: () => { toast({ - title: "Filter deleted", - description: "The LDAP filter has been successfully deleted.", + title: "Success", + description: "Filter deleted successfully", }); - queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); - setFilterToDelete(null); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnectionId, "ldap-filters"] }); + resetForm(); }, - onError: (error) => { + onError: (error: Error) => { toast({ - title: "Error", - description: `Failed to delete filter: ${error.message}`, + title: "Error deleting filter", + description: error.message, variant: "destructive", }); - }, + } }); - // Test filter + // Mutation to revert to a previous version + const revertToRevisionMutation = useMutation({ + mutationFn: async (revisionId: number) => { + if (!selectedFilter) throw new Error("No filter selected"); + const res = await apiRequest("POST", `/api/ldap-filters/${selectedFilter.id}/revert/${revisionId}`, {}); + return await res.json(); + }, + onSuccess: (data) => { + toast({ + title: "Success", + description: "Filter reverted successfully", + }); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnectionId, "ldap-filters"] }); + setSelectedFilter(data); + setFilterName(data.name); + setFilterDescription(data.description || ""); + setFilterQuery(data.ldapFilter); + setIsActive(data.isActive); + setShowRevisionsDialog(false); + }, + onError: (error: Error) => { + toast({ + title: "Error reverting filter", + description: error.message, + variant: "destructive", + }); + } + }); + + // Mutation to test the LDAP filter const testFilterMutation = useMutation({ - mutationFn: async (data: { ldapFilter: string; objectClass: string }) => { - if (!selectedConnection) throw new Error("No connection selected"); - const res = await apiRequest("POST", `/api/connections/${selectedConnection}/test-filter`, data); + mutationFn: async () => { + if (!selectedConnectionId) throw new Error("No connection selected"); + const res = await apiRequest("POST", `/api/connections/${selectedConnectionId}/test-ldap-filter`, { + ldapFilter: filterQuery, + objectClass + }); return await res.json(); }, onSuccess: (data) => { setTestResults(data); - toast({ - title: "Filter tested", - description: `Found ${data.length} results.`, - }); + setShowTestResultsDialog(true); }, - onError: (error) => { + onError: (error: Error) => { toast({ - title: "Test failed", - description: `Failed to test filter: ${error.message}`, + title: "Error testing filter", + description: error.message, variant: "destructive", }); - }, + } }); - // Revert to a previous revision - const revertToRevisionMutation = useMutation({ - mutationFn: async ({ filterId, revisionId }: { filterId: number; revisionId: number }) => { - const res = await apiRequest("POST", `/api/ldap-filters/${filterId}/revert/${revisionId}`); - return await res.json(); - }, - onSuccess: (data) => { - toast({ - title: "Filter reverted", - description: "The filter has been reverted to the selected revision.", - }); - queryClient.invalidateQueries({ queryKey: ["/api/ldap-filters", filterToEdit?.id] }); - queryClient.invalidateQueries({ queryKey: ["/api/ldap-filters", filterToEdit?.id, "revisions"] }); - queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); - setRevisionToRevert(null); - - // Update the current filter with the reverted data - if (filterToEdit) { - setFilterToEdit(data); - setCurrentFilter(data.filter); - setLdapFilter(data.ldapFilter); - } - }, - onError: (error) => { - toast({ - title: "Revert failed", - description: `Failed to revert filter: ${error.message}`, - variant: "destructive", - }); - }, - }); + // Reset form + const resetForm = () => { + setSelectedFilter(null); + setFilterName(""); + setFilterDescription(""); + setFilterQuery(""); + setIsActive(true); + }; - // Handle filter selection + // Select filter const handleSelectFilter = (filter: LdapFilter) => { - setFilterToEdit(filter); + setSelectedFilter(filter); setFilterName(filter.name); setFilterDescription(filter.description || ""); - setCurrentFilter(filter.filter); - setLdapFilter(filter.ldapFilter); - setSelectedObjectClass(filter.objectClass); + setFilterQuery(filter.ldapFilter); + setIsActive(filter.isActive); }; - // Convert the filter object to LDAP filter string - const buildLdapFilter = (filter: FilterGroup): string => { - if (!filter.conditions || filter.conditions.length === 0) { - return "(objectClass=*)"; - } - - const operator = filter.type === "AND" ? "&" : "|"; - const conditions = filter.conditions.map(condition => { - if ('type' in condition) { - // This is a nested group - return buildLdapFilter(condition); - } else { - // This is a basic condition - let value = condition.value; - let attr = condition.attribute; - - switch (condition.operator) { - case "equals": - return `(${attr}=${value})`; - case "notEquals": - return `(!(${attr}=${value}))`; - case "contains": - return `(${attr}=*${value}*)`; - case "startsWith": - return `(${attr}=${value}*)`; - case "endsWith": - return `(${attr}=*${value})`; - case "exists": - return `(${attr}=*)`; - case "notExists": - return `(!(${attr}=*))`; - default: - return `(${attr}=${value})`; - } - } - }); - - if (conditions.length === 1) { - return conditions[0]; - } - - return `(${operator}${conditions.join('')})`; - }; - - // Add a new condition to the filter - const addCondition = () => { - // Find the first attribute for default - const defaultAttribute = attributes.length > 0 ? attributes[0].name : ""; - - const newCondition: FilterCondition = { - attribute: defaultAttribute, - operator: "equals", - value: "" - }; - - setCurrentFilter({ - ...currentFilter, - conditions: [...currentFilter.conditions, newCondition] - }); - }; - - // Add a new group to the filter - const addGroup = () => { - const newGroup: FilterGroup = { - type: "AND", - conditions: [] - }; - - setCurrentFilter({ - ...currentFilter, - conditions: [...currentFilter.conditions, newGroup] - }); - }; - - // Update a condition in the filter - const updateCondition = (index: number, field: keyof FilterCondition, value: string) => { - const updatedConditions = [...currentFilter.conditions]; - const condition = updatedConditions[index] as FilterCondition; - - if ('attribute' in condition) { - (condition as any)[field] = value; - setCurrentFilter({ - ...currentFilter, - conditions: updatedConditions - }); - } - }; - - // Update a group in the filter - const updateGroup = (index: number, type: "AND" | "OR") => { - const updatedConditions = [...currentFilter.conditions]; - const group = updatedConditions[index] as FilterGroup; - - if ('type' in group) { - group.type = type; - setCurrentFilter({ - ...currentFilter, - conditions: updatedConditions - }); - } - }; - - // Remove a condition from the filter - const removeCondition = (index: number) => { - const updatedConditions = [...currentFilter.conditions]; - updatedConditions.splice(index, 1); - - setCurrentFilter({ - ...currentFilter, - conditions: updatedConditions - }); - }; - - // Handle saving the filter + // Save filter const handleSaveFilter = () => { - if (!selectedConnection) { + if (!filterName || !filterQuery) { toast({ - title: "No connection selected", - description: "Please select an LDAP connection first.", + title: "Validation Error", + description: "Filter name and query are required", variant: "destructive", }); return; } - if (!filterName) { - toast({ - title: "Missing filter name", - description: "Please provide a name for the filter.", - variant: "destructive", - }); - return; - } - - const generatedLdapFilter = buildLdapFilter(currentFilter); - const filterData = { name: filterName, - description: filterDescription, - objectClass: selectedObjectClass, - filter: currentFilter, - ldapFilter: generatedLdapFilter + description: filterDescription || null, + ldapFilter: filterQuery, + filter: filterBuilder, + isActive }; - if (filterToEdit) { - updateFilterMutation.mutate({ id: filterToEdit.id, data: filterData }); + if (selectedFilter) { + updateFilterMutation.mutate(filterData); } else { createFilterMutation.mutate(filterData); } }; - // Handle testing the filter - const handleTestFilter = () => { - if (!selectedConnection) { - toast({ - title: "No connection selected", - description: "Please select an LDAP connection first.", - variant: "destructive", - }); - return; + // Render connections dropdown + const renderConnectionsDropdown = () => { + if (connectionsLoading) { + return + + ; } - const generatedLdapFilter = buildLdapFilter(currentFilter); - - testFilterMutation.mutate({ - ldapFilter: generatedLdapFilter, - objectClass: selectedObjectClass - }); + return ( + + ); }; - // Reset the filter form - const resetFilterForm = () => { - setFilterToEdit(null); - setFilterName(""); - setFilterDescription(""); - setCurrentFilter({ - type: "AND", - conditions: [] - }); - setLdapFilter(""); - setShowHistory(false); - setTestResults([]); - }; + // Render filters list + const renderFiltersList = () => { + if (!selectedConnectionId) { + return

Select a connection to view filters

; + } - // Handle filter type change - const handleFilterTypeChange = (type: "AND" | "OR") => { - setCurrentFilter({ - ...currentFilter, - type - }); - }; + if (filtersLoading) { + return
+ +
; + } - // Update LDAP filter when filter conditions change - useEffect(() => { - const generatedFilter = buildLdapFilter(currentFilter); - setLdapFilter(generatedFilter); - }, [currentFilter]); + if (!filters || filters.length === 0) { + return

No filters found for this connection

; + } - // Columns for the saved filters table - const filterColumns: Array<{ - header: string; - accessorKey: keyof LdapFilter | ((row: LdapFilter) => React.ReactNode); - cell?: (row: LdapFilter) => React.ReactNode; - }> = [ - { - header: "Name", - accessorKey: "name", - }, - { - header: "Object Class", - accessorKey: "objectClass", - }, - { - header: "Description", - accessorKey: "description", - }, - { - header: "Last Modified", - accessorKey: "modifiedAt", - cell: (row: LdapFilter) => format(new Date(row.modifiedAt), 'MMM dd, yyyy HH:mm'), - }, - { - header: "Version", - accessorKey: "currentVersion", - }, - { - header: "Actions", - accessorKey: "id", // Use a valid property but render with cell - cell: (row: LdapFilter) => ( -
- - -
- ), - }, - ]; +
+
+

{filter.name}

+ {filter.description && ( +

{filter.description}

+ )} +
+ {!filter.isActive && ( + + Inactive + + )} +
+ + ))} + + ); + }; - // Columns for the revisions table - const revisionColumns: Array<{ - header: string; - accessorKey: keyof LdapFilterRevision | ((row: LdapFilterRevision) => React.ReactNode); - cell?: (row: LdapFilterRevision) => React.ReactNode; - }> = [ - { - header: "Version", - accessorKey: "version", - }, - { - header: "Created At", - accessorKey: "createdAt", - cell: (row: LdapFilterRevision) => format(new Date(row.createdAt), 'MMM dd, yyyy HH:mm'), - }, - { - header: "Comment", - accessorKey: "comment", - }, - { - header: "Actions", - accessorKey: "id", - cell: (row: LdapFilterRevision) => ( - - ), - }, - ]; + // Render revisions dialog + const renderRevisionsDialog = () => { + return ( + + + + Revision History + + View and restore previous versions of "{selectedFilter?.name}" + + - // Columns for the test results table - const testResultColumns: Array<{ - header: string; - accessorKey: string; - cell?: (row: any) => React.ReactNode; - }> = [ - { - header: "Name", - accessorKey: "name", - }, - { - header: "Distinguished Name", - accessorKey: "dn", - }, - ]; + {revisionsLoading ? ( +
+ +
+ ) : ( +
+ + + + Date + Modified By + LDAP Filter + Actions + + + + {revisions && revisions.length > 0 ? ( + revisions.map((rev: LdapFilterRevision) => ( + + + {new Date(rev.modifiedAt).toLocaleString()} + + User ID: {rev.modifiedBy} + + + {rev.ldapFilter} + + + + + + + )) + ) : ( + + + No revision history found + + + )} + +
+
+ )} + + + + +
+
+ ); + }; + + // Render test results dialog + const renderTestResultsDialog = () => { + return ( + + + + Test Results + + Results for LDAP filter: {filterQuery} + + + +
+ {testResults.length > 0 ? ( + + Found {testResults.length} results + + + {Object.keys(testResults[0]).map((key) => ( + {key} + ))} + + + + {testResults.map((result, index) => ( + + {Object.entries(result).map(([key, value]) => ( + + {typeof value === 'object' + ? JSON.stringify(value) + : String(value)} + + ))} + + ))} + +
+ ) : ( +

No results found for this filter

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

- Build complex LDAP queries with a visual interface. Save filters for later use, test them against your Active Directory, and manage filter versions. -

+ +
+
+
+

LDAP Query Builder

+

+ Create, test and manage LDAP queries for Active Directory +

+
-
- -
-
-
- {/* Connection and Object Class Selection */} - - - Connection Settings - Select a connection and object class for your filter - - -
-
- - -
-
- - -
-
-
-
- - {/* Filter Builder */} - - - Filter Builder - Build your LDAP filter by adding conditions - - -
-
- - setFilterName(e.target.value)} - placeholder="Enter a name for this filter" - /> -
-
- - setFilterDescription(e.target.value)} - placeholder="Enter a description" - /> -
-
- -
- -
- - -
-
- -
- {currentFilter.conditions.map((condition, index) => { - if ('type' in condition) { - // This is a nested group (not implementing in this version) - return ( -
-
-
- - -
- -
-

- Nested groups are not fully implemented in this version. -

+
+ {/* Left Side - Filters List */} +
+ + + LDAP Filters + Select a connection to view filters + + +
+
+ +
+ {renderConnectionsDropdown()}
- ); - } - - return ( -
-
- - + New Filter +
-
- - -
-
- - updateCondition(index, 'value', e.target.value)} - placeholder="Value" - disabled={["exists", "notExists"].includes(condition.operator)} + {renderFiltersList()} +
+
+ + +
+ + {/* Right Side - Filter Editor */} +
+ + + + {selectedFilter ? `Edit Filter: ${selectedFilter.name}` : 'Create New Filter'} + + + {selectedFilter + ? 'Update your LDAP filter and test it against your Active Directory' + : 'Create a new LDAP filter and test it against your Active Directory'} + + + +
+
+
+ + setFilterName(e.target.value)} + placeholder="Enter a name for your filter" />
-
+
+ + +
+
+ +
+ + setFilterDescription(e.target.value)} + placeholder="Enter a description" + /> +
+ + + + Manual LDAP Query + + Visual Query Builder (Coming Soon) + + + +
+ +