From fb68574adbbc682721186354fe0111c9a54e70ac Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Tue, 8 Apr 2025 20:22:50 +0000 Subject: [PATCH] Improve LDAP query builder with enhanced attribute display and type handling 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/afff49ca-ba32-4dcb-b9c2-39ee146e28cb.jpg --- .../ldap/enhanced-ldap-query-builder.tsx | 682 ++++++++++++++++++ .../components/ldap/ldap-query-builder.tsx | 41 +- client/src/lib/ldap-types.ts | 36 + client/src/pages/ldap-query-builder-page.tsx | 10 +- server/ldap-query-builder-routes.ts | 15 +- server/ldap.ts | 67 +- 6 files changed, 807 insertions(+), 44 deletions(-) create mode 100644 client/src/components/ldap/enhanced-ldap-query-builder.tsx create mode 100644 client/src/lib/ldap-types.ts diff --git a/client/src/components/ldap/enhanced-ldap-query-builder.tsx b/client/src/components/ldap/enhanced-ldap-query-builder.tsx new file mode 100644 index 0000000..3319b81 --- /dev/null +++ b/client/src/components/ldap/enhanced-ldap-query-builder.tsx @@ -0,0 +1,682 @@ +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, Info } 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"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +// Import shared types from our shared lib +import { LdapOperator, LdapCondition, LdapAttribute } from "@/lib/ldap-types"; + +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 EnhancedLdapQueryBuilder({ + 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, + }); + + // Group attributes by their type for better organization + const groupedAttributes = React.useMemo(() => { + const grouped = { + string: availableAttributes.filter(attr => !attr.type || attr.type === "string"), + number: availableAttributes.filter(attr => attr.type === "number"), + datetime: availableAttributes.filter(attr => attr.type === "datetime"), + boolean: availableAttributes.filter(attr => attr.type === "boolean"), + }; + + return grouped; + }, [availableAttributes]); + + // 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].name : 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 }); + } + }; + + // Get recommended operators based on attribute type + const getRecommendedOperators = (attributeName: string | undefined): LdapOperator[] => { + if (!attributeName) return comparisonOperators; + + const attribute = availableAttributes.find(attr => attr.name === attributeName); + if (!attribute) return comparisonOperators; + + // Return appropriate operators based on type + switch(attribute.type) { + case "number": + return [ + LdapOperator.EQUALS, + LdapOperator.NOT_EQUALS, + LdapOperator.GREATER_THAN, + LdapOperator.LESS_THAN, + LdapOperator.PRESENT + ]; + case "datetime": + return [ + LdapOperator.EQUALS, + LdapOperator.NOT_EQUALS, + LdapOperator.GREATER_THAN, + LdapOperator.LESS_THAN, + LdapOperator.PRESENT + ]; + case "boolean": + return [ + LdapOperator.EQUALS, + LdapOperator.NOT_EQUALS, + LdapOperator.PRESENT + ]; + default: // string + return [ + LdapOperator.EQUALS, + LdapOperator.NOT_EQUALS, + LdapOperator.STARTS_WITH, + LdapOperator.ENDS_WITH, + LdapOperator.CONTAINS, + LdapOperator.PRESENT, + LdapOperator.APPROX + ]; + } + }; + + // UI component for rendering a single condition + const ConditionItem = ({ + condition, + path = [] + }: { + condition: LdapCondition; + path: number[]; + }) => { + const isLogical = logicalOperators.includes(condition.operator); + const selectedAttribute = availableAttributes.find(attr => attr.name === condition.attribute); + const recommendedOperators = !isLogical ? getRecommendedOperators(condition.attribute) : []; + + 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.name} + {attr.isMultiValued && *} + + + +
+

{attr.name}

+ {attr.description &&

{attr.description}

} +

Type: {attr.type || 'string'}

+ {attr.isMultiValued && ( +

Multi-valued attribute

+ )} +
+
+
+
+ ))} +
+
+ )} +
+
+ + {onTest && ( + + )} + {onSave && ( + + )} + +
+ ); +} \ No newline at end of file diff --git a/client/src/components/ldap/ldap-query-builder.tsx b/client/src/components/ldap/ldap-query-builder.tsx index 59e2b22..65e0f6c 100644 --- a/client/src/components/ldap/ldap-query-builder.tsx +++ b/client/src/components/ldap/ldap-query-builder.tsx @@ -29,31 +29,8 @@ 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[]; -}; +// Import shared types from our shared lib +import { LdapOperator, LdapCondition, LdapAttribute } from "@/lib/ldap-types"; export type LdapQueryBuilderParams = { targetObject: "users" | "groups" | "computers" | "ous"; @@ -111,7 +88,7 @@ export function LdapQueryBuilder({ ); // Load available attributes based on connection and object type - const { data: availableAttributes = [], isLoading: isLoadingAttributes } = useQuery({ + const { data: availableAttributes = [], isLoading: isLoadingAttributes } = useQuery({ queryKey: ["/api/ldap-queries/attributes", { connectionId: selectedConnectionId, targetObject: value.targetObject }], enabled: !!selectedConnectionId && !!value.targetObject, }); @@ -179,7 +156,7 @@ export function LdapQueryBuilder({ parent.conditions.push({ operator: LdapOperator.EQUALS, - attribute: availableAttributes.length > 0 ? availableAttributes[0] : undefined, + attribute: availableAttributes.length > 0 ? availableAttributes[0].name : undefined, value: "", }); @@ -237,7 +214,7 @@ export function LdapQueryBuilder({ updatedCondition.conditions = []; } else { delete updatedCondition.conditions; - updatedCondition.attribute = availableAttributes.length > 0 ? availableAttributes[0] : undefined; + updatedCondition.attribute = availableAttributes.length > 0 ? availableAttributes[0].name : undefined; updatedCondition.value = ""; } } @@ -294,8 +271,8 @@ export function LdapQueryBuilder({ ) : ( availableAttributes.map((attr) => ( - - {attr} + + {attr.name} {attr.description ? `(${attr.description})` : ''} )) )} @@ -433,8 +410,8 @@ export function LdapQueryBuilder({
{availableAttributes.map((attr) => ( - - {attr} + + {attr.name} {attr.type ? `(${attr.type})` : ''} ))}
diff --git a/client/src/lib/ldap-types.ts b/client/src/lib/ldap-types.ts new file mode 100644 index 0000000..2afa14b --- /dev/null +++ b/client/src/lib/ldap-types.ts @@ -0,0 +1,36 @@ +// LDAP query types +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 +} + +// Type definition for an LDAP condition +export interface LdapCondition { + operator: LdapOperator; + attribute?: string; // Not required for logical operators (AND, OR, NOT) + value?: string; // Not required for some operators (PRESENT, logical operators) + conditions?: LdapCondition[]; // For nested conditions with logical operators +} + +// Type definition for an LDAP attribute +export interface LdapAttribute { + name: string; + type: string; + description?: string; + syntax?: string; + isMultiValued?: boolean; + isMandatory?: boolean; +} \ No newline at end of file diff --git a/client/src/pages/ldap-query-builder-page.tsx b/client/src/pages/ldap-query-builder-page.tsx index a4892b4..251a976 100644 --- a/client/src/pages/ldap-query-builder-page.tsx +++ b/client/src/pages/ldap-query-builder-page.tsx @@ -3,7 +3,9 @@ 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 { LdapQueryBuilderParams } from "@/components/ldap/ldap-query-builder"; +import { EnhancedLdapQueryBuilder } from "@/components/ldap/enhanced-ldap-query-builder"; +import { LdapCondition, LdapOperator } from "@/lib/ldap-types"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -180,7 +182,7 @@ export default function LdapQueryBuilderPage() { setActiveQuery(query); setQueryBuilderParams({ targetObject: query.targetObject as "users" | "groups" | "computers" | "ous", - filter: query.filter + filter: query.filterJson as LdapCondition }); setQueryName(query.name); setQueryDescription(query.description || ""); @@ -268,7 +270,7 @@ export default function LdapQueryBuilderPage() { testQueryMutation.mutate({ connectionId: connections.length > 0 ? connections[0].id : 0, targetObject: row.targetObject, - filter: row.filter, + filter: row.filterJson as LdapCondition, limit: 100 }); }}> @@ -417,7 +419,7 @@ export default function LdapQueryBuilderPage() { ) : null}
- { +): Promise { debug(`Getting available attributes for ${targetObject}`); // Define common attributes for each object type @@ -239,21 +248,65 @@ export async function getLdapAvailableAttributes( } // Combine common and specific attributes, then add dynamic attributes - let allAttributes = [...commonAttributes, ...specificAttributes[targetObject]]; + let allAttributeNames = [...commonAttributes, ...specificAttributes[targetObject]]; // Add any dynamic attributes that aren't already in our list dynamicAttributes.forEach(attr => { - if (!allAttributes.includes(attr)) { - allAttributes.push(attr); + if (!allAttributeNames.includes(attr)) { + allAttributeNames.push(attr); } }); - // Sort alphabetically - return allAttributes.sort(); + // Convert string attributes to metadata objects and sort alphabetically by name + const attributeMetadata: LdapAttributeMetadata[] = allAttributeNames.map((name: string) => { + // Attribute type inference based on common patterns + let type = "string"; + if (name.toLowerCase().includes("count") || name.toLowerCase().includes("id") || + name.endsWith("Type") || name.includes("ID")) { + type = "number"; + } else if (name.toLowerCase().includes("time") || name.toLowerCase().includes("date") || + name.toLowerCase().includes("created") || name.toLowerCase().includes("changed") || + name.toLowerCase().includes("expires")) { + type = "datetime"; + } else if (name.toLowerCase().includes("is") || name.toLowerCase().includes("has") || + name.toLowerCase().includes("enabled") || name.toLowerCase().includes("disabled")) { + type = "boolean"; + } + + // Description inference based on name + let description = ""; + if (name === "cn") description = "Common Name"; + else if (name === "sn") description = "Surname"; + else if (name === "givenName") description = "First Name"; + else if (name === "sAMAccountName") description = "Login Name"; + else if (name === "userPrincipalName") description = "User Principal Name"; + else if (name === "mail") description = "Email Address"; + else if (name === "memberOf") description = "Group Memberships"; + else if (name === "member") description = "Members"; + else if (name === "pwdLastSet") description = "Password Last Set"; + else if (name === "lastLogon") description = "Last Login Time"; + + return { + name, + type, + description: description || undefined, + // For multi-valued attributes we could infer based on name, but would be more accurate + // to use the schema information which we don't fully process here yet + isMultiValued: name === "memberOf" || name === "member" || name === "proxyAddresses" || + name === "objectClass" || name === "servicePrincipalName" + }; + }); + + // Sort alphabetically by name + return attributeMetadata.sort((a, b) => a.name.localeCompare(b.name)); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); debug("Failed to get LDAP available attributes:", error); + // Return a default set of attributes instead of throwing - return [...commonAttributes, ...specificAttributes[targetObject]].sort(); + const defaultAttributes = [...commonAttributes, ...specificAttributes[targetObject]].sort(); + + // Convert to metadata objects with minimal information + return defaultAttributes.map(name => ({ name })); } } \ No newline at end of file