From 6ed03199b714ed1655d5572405a5977eed013a38 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Tue, 8 Apr 2025 20:12:52 +0000 Subject: [PATCH] Add LDAP query builder feature with UI and API endpoints. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 --- client/src/App.tsx | 2 + .../components/ldap/ldap-query-builder.tsx | 464 +++++++++++++++ client/src/layouts/dashboard-layout.tsx | 1 + client/src/pages/ldap-query-builder-page.tsx | 535 ++++++++++++++++++ server/ldap-query-builder-routes.ts | 83 ++- server/ldap.ts | 128 +++++ 6 files changed, 1212 insertions(+), 1 deletion(-) create mode 100644 client/src/components/ldap/ldap-query-builder.tsx 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/components/ldap/ldap-query-builder.tsx b/client/src/components/ldap/ldap-query-builder.tsx new file mode 100644 index 0000000..59e2b22 --- /dev/null +++ b/client/src/components/ldap/ldap-query-builder.tsx @@ -0,0 +1,464 @@ +import React, { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; +import { LdapConnection } from "@shared/schema"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, + CardFooter +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Loader2, Plus, Trash } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; + +// These types are from the server-side LdapFilterBuilder +export enum LdapOperator { + // Logical operators + AND = "and", + OR = "or", + NOT = "not", + + // Comparison operators + EQUALS = "equals", + NOT_EQUALS = "notEquals", + STARTS_WITH = "startsWith", + ENDS_WITH = "endsWith", + CONTAINS = "contains", + GREATER_THAN = "greaterThan", + LESS_THAN = "lessThan", + PRESENT = "present", // attribute exists + APPROX = "approx", // approximately equals +} + +export type LdapCondition = { + operator: LdapOperator; + attribute?: string; + value?: string; + conditions?: LdapCondition[]; +}; + +export type LdapQueryBuilderParams = { + targetObject: "users" | "groups" | "computers" | "ous"; + filter: LdapCondition; +}; + +// Used for displaying the operators in the UI +const operatorLabels: Record = { + [LdapOperator.AND]: "AND (All conditions)", + [LdapOperator.OR]: "OR (Any condition)", + [LdapOperator.NOT]: "NOT", + [LdapOperator.EQUALS]: "Equals", + [LdapOperator.NOT_EQUALS]: "Does not equal", + [LdapOperator.STARTS_WITH]: "Starts with", + [LdapOperator.ENDS_WITH]: "Ends with", + [LdapOperator.CONTAINS]: "Contains", + [LdapOperator.GREATER_THAN]: "Greater than", + [LdapOperator.LESS_THAN]: "Less than", + [LdapOperator.PRESENT]: "Has value (attribute exists)", + [LdapOperator.APPROX]: "Approximately equals", +}; + +// Group operators by type for the UI +const logicalOperators = [LdapOperator.AND, LdapOperator.OR, LdapOperator.NOT]; +const comparisonOperators = [ + LdapOperator.EQUALS, + LdapOperator.NOT_EQUALS, + LdapOperator.STARTS_WITH, + LdapOperator.ENDS_WITH, + LdapOperator.CONTAINS, + LdapOperator.GREATER_THAN, + LdapOperator.LESS_THAN, + LdapOperator.PRESENT, + LdapOperator.APPROX, +]; + +interface LdapQueryBuilderProps { + connections: LdapConnection[]; + value: LdapQueryBuilderParams; + onChange: (value: LdapQueryBuilderParams) => void; + onTest?: () => void; + onSave?: () => void; +} + +export function LdapQueryBuilder({ + connections, + value, + onChange, + onTest, + onSave +}: LdapQueryBuilderProps) { + const { toast } = useToast(); + const [selectedConnectionId, setSelectedConnectionId] = useState( + connections.length > 0 ? connections[0].id : null + ); + + // Load available attributes based on connection and object type + const { data: availableAttributes = [], isLoading: isLoadingAttributes } = useQuery({ + queryKey: ["/api/ldap-queries/attributes", { connectionId: selectedConnectionId, targetObject: value.targetObject }], + enabled: !!selectedConnectionId && !!value.targetObject, + }); + + // Update the selected connection ID if the connections list changes + useEffect(() => { + if (connections.length > 0 && !selectedConnectionId) { + setSelectedConnectionId(connections[0].id); + } + }, [connections, selectedConnectionId]); + + // Handle changes to the query target object type + const handleTargetObjectChange = (targetObject: "users" | "groups" | "computers" | "ous") => { + onChange({ + ...value, + targetObject, + }); + }; + + // Handle updates to a condition + const handleConditionChange = ( + condition: LdapCondition, + path: number[] = [] + ): LdapCondition => { + if (path.length === 0) { + return condition; + } + + const [index, ...restPath] = path; + const updatedConditions = [...(value.filter.conditions || [])]; + + if (restPath.length === 0) { + updatedConditions[index] = condition; + } else { + updatedConditions[index] = handleConditionChange( + updatedConditions[index], + restPath + ); + } + + return { + ...value.filter, + conditions: updatedConditions, + }; + }; + + // Add a new condition to a logical operator + const handleAddCondition = (path: number[] = []) => { + let currentCondition = value.filter; + let parent = currentCondition; + + // Navigate to the target condition + for (const index of path) { + if (!currentCondition.conditions) { + currentCondition.conditions = []; + } + parent = currentCondition; + currentCondition = currentCondition.conditions[index]; + } + + // Add a new condition + if (!parent.conditions) { + parent.conditions = []; + } + + parent.conditions.push({ + operator: LdapOperator.EQUALS, + attribute: availableAttributes.length > 0 ? availableAttributes[0] : undefined, + value: "", + }); + + onChange({ ...value }); + }; + + // Remove a condition + const handleRemoveCondition = (path: number[]) => { + if (path.length === 0) return; + + const parentPath = path.slice(0, -1); + const index = path[path.length - 1]; + + let currentCondition = value.filter; + + // Navigate to the parent condition + for (const idx of parentPath) { + if (!currentCondition.conditions) return; + currentCondition = currentCondition.conditions[idx]; + } + + // Remove the condition + if (currentCondition.conditions) { + currentCondition.conditions = currentCondition.conditions.filter((_, i) => i !== index); + onChange({ ...value }); + } + }; + + // UI component for rendering a single condition + const ConditionItem = ({ + condition, + path = [] + }: { + condition: LdapCondition; + path: number[]; + }) => { + const isLogical = logicalOperators.includes(condition.operator); + + return ( +
+
+ + + {!isLogical && ( + <> + + + {condition.operator !== LdapOperator.PRESENT && ( + { + const updatedCondition = { ...condition, value: e.target.value }; + const newFilter = handleConditionChange(updatedCondition, path); + onChange({ ...value, filter: newFilter }); + }} + /> + )} + + )} + + {path.length > 0 && ( + + )} +
+ + {isLogical && ( +
+ {condition.conditions?.map((childCondition, index) => ( + + ))} + +
+ )} +
+ ); + }; + + return ( + + + LDAP Query Builder + + Build LDAP queries with a visual interface + + + +
+
+
+ + +
+
+ + +
+
+ +
+ + + + Visual Builder + LDAP Filter Preview + + + + + +
+
+                    {JSON.stringify(value.filter, null, 2)}
+                  
+
+
+
+
+ + {availableAttributes.length > 0 && ( +
+ +
+ {availableAttributes.map((attr) => ( + + {attr} + + ))} +
+
+ )} +
+
+ + {onTest && ( + + )} + {onSave && ( + + )} + +
+ ); +} \ No newline at end of file diff --git a/client/src/layouts/dashboard-layout.tsx b/client/src/layouts/dashboard-layout.tsx index bd72bad..6f7bf91 100644 --- a/client/src/layouts/dashboard-layout.tsx +++ b/client/src/layouts/dashboard-layout.tsx @@ -67,6 +67,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..a4892b4 --- /dev/null +++ b/client/src/pages/ldap-query-builder-page.tsx @@ -0,0 +1,535 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; +import { DashboardLayout } from "@/layouts/dashboard-layout"; +import { LdapConnection, LdapQuery } from "@shared/schema"; +import { LdapQueryBuilder, LdapQueryBuilderParams, LdapOperator } from "@/components/ldap/ldap-query-builder"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DataTable } from "@/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { Loader2, MoreHorizontal, Plus, Code, Save, Play, FileClock } from "lucide-react"; +import { format } from "date-fns"; + +export default function LdapQueryBuilderPage() { + const { toast } = useToast(); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showTestDialog, setShowTestDialog] = useState(false); + const [testResults, setTestResults] = useState(null); + + const [queryName, setQueryName] = useState(""); + const [queryDescription, setQueryDescription] = useState(""); + + const [activeQuery, setActiveQuery] = useState(null); + + const [queryBuilderParams, setQueryBuilderParams] = useState({ + targetObject: "users", + filter: { + operator: LdapOperator.AND, + conditions: [] + } + }); + + // Fetch all saved queries + const { data: savedQueries = [], isLoading: isLoadingQueries } = useQuery({ + queryKey: ["/api/ldap-queries"], + }); + + // Fetch LDAP connections for the query builder + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + // Mutation for creating a new query + const createQueryMutation = useMutation({ + mutationFn: async (data: { + name: string; + description: string; + targetObject: string; + filter: any; + }) => { + const response = await apiRequest("POST", "/api/ldap-queries", data); + return await response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/ldap-queries"] }); + setShowCreateDialog(false); + setQueryName(""); + setQueryDescription(""); + toast({ + title: "Query saved", + description: "The LDAP query has been saved successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error saving query", + description: error.message, + variant: "destructive", + }); + } + }); + + // Mutation for testing a query + const testQueryMutation = useMutation({ + mutationFn: async (data: { + connectionId: number; + targetObject: string; + filter: any; + limit?: number; + }) => { + const response = await apiRequest("POST", "/api/ldap-queries/test", data); + return await response.json(); + }, + onSuccess: (data) => { + setTestResults(data.results); + setShowTestDialog(true); + toast({ + title: "Query executed", + description: `Query returned ${data.count} results.`, + }); + }, + onError: (error) => { + toast({ + title: "Error testing query", + description: error.message, + variant: "destructive", + }); + } + }); + + // Mutation for updating an existing query + const updateQueryMutation = useMutation({ + mutationFn: async (data: { + id: number; + name: string; + description: string; + targetObject: string; + filter: any; + }) => { + const response = await apiRequest("PUT", `/api/ldap-queries/${data.id}`, data); + return await response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/ldap-queries"] }); + setActiveQuery(null); + toast({ + title: "Query updated", + description: "The LDAP query has been updated successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error updating query", + description: error.message, + variant: "destructive", + }); + } + }); + + // Function to handle query deletion + const deleteQuery = async (id: number) => { + try { + await apiRequest("DELETE", `/api/ldap-queries/${id}`); + queryClient.invalidateQueries({ queryKey: ["/api/ldap-queries"] }); + toast({ + title: "Query deleted", + description: "The LDAP query has been deleted successfully.", + }); + } catch (error: any) { + toast({ + title: "Error deleting query", + description: error.message, + variant: "destructive", + }); + } + }; + + // Function to load a query for editing + const loadQuery = (query: LdapQuery) => { + setActiveQuery(query); + setQueryBuilderParams({ + targetObject: query.targetObject as "users" | "groups" | "computers" | "ous", + filter: query.filter + }); + setQueryName(query.name); + setQueryDescription(query.description || ""); + }; + + // Function to handle saving a query + const handleSaveQuery = () => { + if (activeQuery) { + updateQueryMutation.mutate({ + id: activeQuery.id, + name: queryName, + description: queryDescription, + targetObject: queryBuilderParams.targetObject, + filter: queryBuilderParams.filter + }); + } else { + setShowCreateDialog(true); + } + }; + + // Function to handle testing a query + const handleTestQuery = () => { + if (connections.length === 0) { + toast({ + title: "No connections available", + description: "Please add an LDAP connection first.", + variant: "destructive", + }); + return; + } + + testQueryMutation.mutate({ + connectionId: connections[0].id, + targetObject: queryBuilderParams.targetObject, + filter: queryBuilderParams.filter, + limit: 100 + }); + }; + + // Generate columns for the saved queries table + const queriesColumns = [ + { + header: "Name", + accessorKey: "name", + }, + { + header: "Description", + accessorKey: "description", + }, + { + header: "Target", + accessorKey: "targetObject", + cell: (row: LdapQuery) => { + const targets: Record = { + users: "Users", + groups: "Groups", + computers: "Computers", + ous: "Organizational Units", + }; + return targets[row.targetObject] || row.targetObject; + }, + }, + { + header: "Last Updated", + accessorKey: "updatedAt", + cell: (row: LdapQuery) => + row.updatedAt ? format(new Date(row.updatedAt), "MMM dd, yyyy HH:mm") : "", + }, + { + header: "", + accessorKey: "id", + cell: (row: LdapQuery) => ( + + + + + + loadQuery(row)}> + + Edit + + { + testQueryMutation.mutate({ + connectionId: connections.length > 0 ? connections[0].id : 0, + targetObject: row.targetObject, + filter: row.filter, + limit: 100 + }); + }}> + + Test + + deleteQuery(row.id)}> + + Delete + + + + ), + }, + ]; + + // Generate columns for the test results table dynamically based on the returned attributes + const getTestResultsColumns = () => { + if (!testResults || testResults.length === 0) return []; + + const firstResult = testResults[0]; + return Object.keys(firstResult).map(key => ({ + header: key, + accessorKey: key, + cell: (row: any) => { + const value = row[key]; + if (value === null || value === undefined) return "-"; + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (Array.isArray(value)) return value.join(", "); + return String(value); + } + })); + }; + + return ( + +
+ {/* Left panel - Saved Queries */} +
+ + +
+ Saved Queries + + Reuse and manage your saved LDAP queries + +
+ +
+ +
+ +
+
+
+
+ + {/* Right panel - Query Builder */} +
+ + +
+
+ + {activeQuery ? "Edit Query" : "New Query"} + + + {activeQuery ? `Editing: ${activeQuery.name}` : "Create a new LDAP query"} + +
+
+ + +
+
+
+ +
+ {activeQuery ? ( +
+
+ + setQueryName(e.target.value)} + placeholder="My Query" + /> +
+
+ + setQueryDescription(e.target.value)} + placeholder="What does this query do?" + /> +
+
+ ) : null} + +
+ +
+
+
+
+
+
+ + {/* Create Query Dialog */} + + + + Save Query + + Give your query a name and description for future reference. + + +
+
+ + setQueryName(e.target.value)} + placeholder="Enter a name for your query" + /> +
+
+ +