From 98159c0d308815f997f16bc5af8d7bf9fb4d2773 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Tue, 8 Apr 2025 21:43:51 +0000 Subject: [PATCH] Add LDAP query builder feature with UI and API endpoints for managing LDAP attributes and filters. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/12f8d069-cc9a-4b87-88eb-6a7e3bcb8c9c.jpg --- client/src/App.tsx | 2 + client/src/layouts/dashboard-layout.tsx | 2 + client/src/pages/ldap-query-builder-page.tsx | 970 +++++++++++++++++++ server/routes.ts | 741 ++++++++++++++ server/storage.ts | 353 ++++++- shared/schema.ts | 111 ++- 6 files changed, 2177 insertions(+), 2 deletions(-) create mode 100644 client/src/pages/ldap-query-builder-page.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 5393fbf..aa442dc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import ComputersPage from "@/pages/computers-page"; import DomainsPage from "@/pages/domains-page"; import ApiTokensPage from "@/pages/api-tokens-page"; import LdapConnectionsPage from "@/pages/ldap-connections-page"; +import LdapQueryBuilderPage from "@/pages/ldap-query-builder-page"; import SettingsPage from "@/pages/settings-page"; import UserManagementPage from "@/pages/user-management-page"; import { ProtectedRoute } from "./lib/protected-route"; @@ -30,6 +31,7 @@ function Router() { + diff --git a/client/src/layouts/dashboard-layout.tsx b/client/src/layouts/dashboard-layout.tsx index bd72bad..785c2d7 100644 --- a/client/src/layouts/dashboard-layout.tsx +++ b/client/src/layouts/dashboard-layout.tsx @@ -28,6 +28,7 @@ import { Key, Server, ShieldAlert, + Filter, } from "lucide-react"; import { useMobile } from "@/hooks/use-mobile"; import { useToast } from "@/hooks/use-toast"; @@ -67,6 +68,7 @@ const menuSections: MenuSection[] = [ items: [ { title: "API Tokens", path: "/api-tokens", icon: }, { title: "LDAP Connections", path: "/ldap-connections", icon: }, + { title: "LDAP Query Builder", path: "/ldap-query-builder", icon: }, { title: "Settings", path: "/settings", icon: }, { title: "User Management", path: "/user-management", icon: }, ], diff --git a/client/src/pages/ldap-query-builder-page.tsx b/client/src/pages/ldap-query-builder-page.tsx new file mode 100644 index 0000000..400e941 --- /dev/null +++ b/client/src/pages/ldap-query-builder-page.tsx @@ -0,0 +1,970 @@ +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 { 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 { 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"; + +// 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 { + id: number; + name: string; + server: string; + domain: string; + port: number; + useSSL: boolean; + status: string; +} + +interface FilterCondition { + attribute: string; + operator: string; + value: string; +} + +interface FilterGroup { + type: "AND" | "OR"; + conditions: (FilterCondition | FilterGroup)[]; +} + +export default function LdapQueryBuilderPage() { + const { toast } = useToast(); + const [selectedConnection, setSelectedConnection] = useState(null); + const [selectedObjectClass, setSelectedObjectClass] = useState("user"); + const [filterToEdit, setFilterToEdit] = useState(null); + const [filterToDelete, setFilterToDelete] = 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 [testResults, setTestResults] = useState([]); + const [revisionToRevert, setRevisionToRevert] = useState(null); + + // Get all LDAP connections + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + // 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, + }); + + // 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, + }); + + // 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 + const createFilterMutation = useMutation({ + mutationFn: async (filterData: any) => { + const res = await apiRequest("POST", `/api/connections/${selectedConnection}/ldap-filters`, filterData); + return await res.json(); + }, + onSuccess: () => { + toast({ + title: "Filter created", + description: "The LDAP filter has been successfully created.", + }); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); + resetFilterForm(); + }, + onError: (error) => { + toast({ + title: "Error", + description: `Failed to create filter: ${error.message}`, + variant: "destructive", + }); + }, + }); + + // Update filter + const updateFilterMutation = useMutation({ + mutationFn: async ({ id, data }: { id: number; data: any }) => { + const res = await apiRequest("PUT", `/api/ldap-filters/${id}`, data); + return await res.json(); + }, + onSuccess: () => { + toast({ + title: "Filter updated", + description: "The LDAP filter has been successfully updated.", + }); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); + resetFilterForm(); + }, + onError: (error) => { + toast({ + title: "Error", + description: `Failed to update filter: ${error.message}`, + variant: "destructive", + }); + }, + }); + + // Delete filter + const deleteFilterMutation = useMutation({ + mutationFn: async (id: number) => { + await apiRequest("DELETE", `/api/ldap-filters/${id}`); + }, + onSuccess: () => { + toast({ + title: "Filter deleted", + description: "The LDAP filter has been successfully deleted.", + }); + queryClient.invalidateQueries({ queryKey: ["/api/connections", selectedConnection, "ldap-filters"] }); + setFilterToDelete(null); + }, + onError: (error) => { + toast({ + title: "Error", + description: `Failed to delete filter: ${error.message}`, + variant: "destructive", + }); + }, + }); + + // Test 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); + return await res.json(); + }, + onSuccess: (data) => { + setTestResults(data); + toast({ + title: "Filter tested", + description: `Found ${data.length} results.`, + }); + }, + onError: (error) => { + toast({ + title: "Test failed", + description: `Failed to test filter: ${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", + }); + }, + }); + + // Handle filter selection + const handleSelectFilter = (filter: LdapFilter) => { + setFilterToEdit(filter); + setFilterName(filter.name); + setFilterDescription(filter.description || ""); + setCurrentFilter(filter.filter); + setLdapFilter(filter.ldapFilter); + setSelectedObjectClass(filter.objectClass); + }; + + // 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 + const handleSaveFilter = () => { + if (!selectedConnection) { + toast({ + title: "No connection selected", + description: "Please select an LDAP connection first.", + 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 + }; + + if (filterToEdit) { + updateFilterMutation.mutate({ id: filterToEdit.id, data: 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; + } + + const generatedLdapFilter = buildLdapFilter(currentFilter); + + testFilterMutation.mutate({ + ldapFilter: generatedLdapFilter, + objectClass: selectedObjectClass + }); + }; + + // Reset the filter form + const resetFilterForm = () => { + setFilterToEdit(null); + setFilterName(""); + setFilterDescription(""); + setCurrentFilter({ + type: "AND", + conditions: [] + }); + setLdapFilter(""); + setShowHistory(false); + setTestResults([]); + }; + + // Handle filter type change + const handleFilterTypeChange = (type: "AND" | "OR") => { + setCurrentFilter({ + ...currentFilter, + type + }); + }; + + // Update LDAP filter when filter conditions change + useEffect(() => { + const generatedFilter = buildLdapFilter(currentFilter); + setLdapFilter(generatedFilter); + }, [currentFilter]); + + // 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) => ( +
+ + +
+ ), + }, + ]; + + // 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) => ( + + ), + }, + ]; + + // 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", + }, + ]; + + return ( + +
+
+

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

+
+
+ +
+
+ +
+ {/* 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. +

+
+ ); + } + + return ( +
+
+ + +
+
+ + +
+
+ + updateCondition(index, 'value', e.target.value)} + placeholder="Value" + disabled={["exists", "notExists"].includes(condition.operator)} + /> +
+
+ +
+
+ ); + })} + +
+ + +
+
+ +
+ +