diff --git a/client/src/App.tsx b/client/src/App.tsx index 5f233a7..50cb325 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -21,6 +21,7 @@ import SettingsPage from "@/pages/settings-page"; import UserManagementPage from "@/pages/user-management-page"; import AuditLogsPage from "@/pages/audit-logs-page"; import SitesPage from "@/pages/sites-page"; +import DynamicGroupsPage from "@/pages/dynamic-groups-page"; export default function App() { return ( @@ -45,6 +46,7 @@ export default function App() { + diff --git a/client/src/components/dynamic-groups/condition-builder.tsx b/client/src/components/dynamic-groups/condition-builder.tsx new file mode 100644 index 0000000..b98b11c --- /dev/null +++ b/client/src/components/dynamic-groups/condition-builder.tsx @@ -0,0 +1,591 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, Plus, Trash, ArrowRightLeft, MoveVertical, Group, Layers } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +interface Condition { + id: number; + type: "condition" | "group"; + parentId: number | null; + operator: string; + attribute?: string; + value?: string; + position: number; + children?: Condition[]; +} + +interface Connection { + id: number; + name: string; + server: string; +} + +interface LdapAttribute { + id: number; + name: string; + displayName: string | null; + description: string | null; + type: string | null; + multiValued: boolean; + objectClass: string; +} + +interface ConditionBuilderProps { + conditions: Condition[]; + onChange: (conditions: Condition[]) => void; + connections: Connection[]; +} + +const conditionOperators = [ + { value: "equals", label: "Equals" }, + { value: "contains", label: "Contains" }, + { value: "startsWith", label: "Starts With" }, + { value: "endsWith", label: "Ends With" }, + { value: "present", label: "Is Present" }, + { value: "notPresent", label: "Is Not Present" }, + { value: "greaterThan", label: "Greater Than" }, + { value: "lessThan", label: "Less Than" }, +]; + +const groupOperators = [ + { value: "and", label: "AND" }, + { value: "or", label: "OR" }, + { value: "not", label: "NOT" }, +]; + +export default function ConditionBuilder({ conditions, onChange, connections }: ConditionBuilderProps) { + const [selectedConnectionId, setSelectedConnectionId] = useState(null); + const [activeId, setActiveId] = useState(null); + const [nextId, setNextId] = useState(1); + + // Ensure we have unique IDs for new conditions + useEffect(() => { + if (conditions.length > 0) { + const maxId = Math.max(...conditions.map(c => c.id)) + 1; + setNextId(maxId); + } + }, [conditions]); + + // Fetch LDAP attributes for the selected connection + const { data: attributes, isLoading: isLoadingAttributes } = useQuery({ + queryKey: ["/api/ldap-attributes", selectedConnectionId], + enabled: !!selectedConnectionId, + retry: false, + }); + + // DnD setup + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as number); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null); + + const { active, over } = event; + + if (over && active.id !== over.id) { + // Handle reordering logic here + const updatedConditions = [...conditions]; + // Implement sorting logic + onChange(updatedConditions); + } + }; + + // Add a new condition to the root level + const addCondition = () => { + const newCondition: Condition = { + id: nextId, + type: "condition", + parentId: null, + operator: "equals", + attribute: "", + value: "", + position: conditions.length, + }; + + onChange([...conditions, newCondition]); + setNextId(nextId + 1); + }; + + // Add a new condition group to the root level + const addGroup = () => { + const newGroup: Condition = { + id: nextId, + type: "group", + parentId: null, + operator: "and", + position: conditions.length, + children: [], + }; + + onChange([...conditions, newGroup]); + setNextId(nextId + 1); + }; + + // Update a condition + const updateCondition = (id: number, updates: Partial) => { + const updatedConditions = conditions.map(condition => { + if (condition.id === id) { + return { ...condition, ...updates }; + } + return condition; + }); + + onChange(updatedConditions); + }; + + // Delete a condition + const deleteCondition = (id: number) => { + const filteredConditions = conditions.filter(condition => condition.id !== id); + onChange(filteredConditions); + }; + + // Add a nested condition inside a group + const addNestedCondition = (parentId: number) => { + const updatedConditions = [...conditions]; + const parentIndex = updatedConditions.findIndex(c => c.id === parentId); + + if (parentIndex !== -1 && updatedConditions[parentIndex].type === "group") { + const parent = updatedConditions[parentIndex]; + const children = parent.children || []; + + const newCondition: Condition = { + id: nextId, + type: "condition", + parentId: parentId, + operator: "equals", + attribute: "", + value: "", + position: children.length, + }; + + updatedConditions[parentIndex] = { + ...parent, + children: [...children, newCondition], + }; + + onChange(updatedConditions); + setNextId(nextId + 1); + } + }; + + return ( +
+
+ + +
+ +
+ + +
+ + + c.id)} + strategy={verticalListSortingStrategy} + > +
+ {conditions.length === 0 ? ( +
+

No conditions defined. Add a condition or group to get started.

+
+ ) : ( + conditions.map(condition => ( + updateCondition(condition.id, updates)} + onDelete={() => deleteCondition(condition.id)} + onAddNested={ + condition.type === "group" + ? () => addNestedCondition(condition.id) + : undefined + } + selectedConnectionId={selectedConnectionId} + /> + )) + )} +
+
+ + + {activeId ? ( +
+ Dragging Item {activeId} +
+ ) : null} +
+
+
+ ); +} + +interface ConditionItemProps { + condition: Condition; + attributes: LdapAttribute[]; + isLoadingAttributes: boolean; + onUpdate: (updates: Partial) => void; + onDelete: () => void; + onAddNested?: () => void; + selectedConnectionId: number | null; +} + +function ConditionItem({ + condition, + attributes, + isLoadingAttributes, + onUpdate, + onDelete, + onAddNested, + selectedConnectionId +}: ConditionItemProps) { + const { + attributes: sortableAttributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: condition.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + if (condition.type === "group") { + return ( +
+ + +
+
+ + + Drag + + + + +
+ +
+ + +
+
+ +
+ {condition.children && condition.children.length > 0 ? ( + condition.children.map((child) => ( + { + const updatedChildren = condition.children?.map((c) => { + if (c.id === child.id) { + return { ...c, ...updates }; + } + return c; + }); + onUpdate({ children: updatedChildren }); + }} + onDelete={() => { + const updatedChildren = condition.children?.filter((c) => c.id !== child.id); + onUpdate({ children: updatedChildren }); + }} + selectedConnectionId={selectedConnectionId} + /> + )) + ) : ( +
+

No conditions in this group.

+ +
+ )} +
+
+
+
+ ); + } + + return ( +
+ + +
+ + + Drag + + +
+ + {isLoadingAttributes ? ( +
+ + Loading attributes... +
+ ) : !selectedConnectionId ? ( + onUpdate({ attribute: e.target.value })} + placeholder="Select a connection first" + className="w-full" + /> + ) : ( + + )} +
+ +
+ + +
+ +
+ + onUpdate({ value: e.target.value })} + placeholder="Value" + className="w-full" + disabled={["present", "notPresent"].includes(condition.operator)} + /> +
+ + +
+
+
+
+ ); +} + +interface NestedConditionItemProps { + condition: Condition; + attributes: LdapAttribute[]; + isLoadingAttributes: boolean; + onUpdate: (updates: Partial) => void; + onDelete: () => void; + selectedConnectionId: number | null; +} + +function NestedConditionItem({ + condition, + attributes, + isLoadingAttributes, + onUpdate, + onDelete, + selectedConnectionId +}: NestedConditionItemProps) { + return ( + + +
+
+ + {isLoadingAttributes ? ( +
+ + Loading... +
+ ) : !selectedConnectionId ? ( + onUpdate({ attribute: e.target.value })} + placeholder="Select a connection first" + className="w-full" + /> + ) : ( + + )} +
+ +
+ + +
+ +
+ + onUpdate({ value: e.target.value })} + placeholder="Value" + className="w-full" + disabled={["present", "notPresent"].includes(condition.operator)} + /> +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/dynamic-groups/rule-editor.tsx b/client/src/components/dynamic-groups/rule-editor.tsx new file mode 100644 index 0000000..448c821 --- /dev/null +++ b/client/src/components/dynamic-groups/rule-editor.tsx @@ -0,0 +1,397 @@ +import React, { useState, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { queryClient, apiRequest } from "@/lib/queryClient"; +import { Loader2, Plus, Save, Trash, Code, Filter } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import ConditionBuilder from "./condition-builder"; + +// Define the schema for rule form +const ruleFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + targetGroup: z.string().min(1, "Target group is required"), + enabled: z.boolean().default(true), + schedule: z.string().min(1, "Schedule is required"), + variablePattern: z.string().optional(), + connectionIds: z.array(z.number()).min(1, "At least one connection is required"), +}); + +type RuleFormValues = z.infer; + +interface Condition { + id: number; + type: "condition" | "group"; + parentId: number | null; + operator: string; + attribute?: string; + value?: string; + position: number; + children?: Condition[]; +} + +interface Connection { + id: number; + name: string; + server: string; +} + +interface DynamicGroupRule { + id?: number; + name: string; + description: string | null; + targetGroup: string; + enabled: boolean; + schedule: string; + variablePattern: string | null; + connectionIds: number[]; + conditions: Condition[]; +} + +interface RuleEditorProps { + ruleId?: number; + onSuccess?: () => void; + onCancel?: () => void; +} + +export default function RuleEditor({ ruleId, onSuccess, onCancel }: RuleEditorProps) { + const { toast } = useToast(); + const [conditions, setConditions] = useState([]); + const [activeTab, setActiveTab] = useState("general"); + const isEditMode = !!ruleId; + + // Fetch connections for dropdown + const { data: connections, isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + retry: false, + }); + + // Fetch rule data if editing + const { data: ruleData, isLoading: isLoadingRule } = useQuery({ + queryKey: ["/api/dynamic-group-rules", ruleId], + enabled: !!ruleId, + retry: false, + }); + + // Form setup + const form = useForm({ + resolver: zodResolver(ruleFormSchema), + defaultValues: { + name: "", + description: "", + targetGroup: "", + enabled: true, + schedule: "0 0 * * *", // Daily at midnight + variablePattern: "", + connectionIds: [], + }, + }); + + // Update form when rule data is loaded + useEffect(() => { + if (ruleData) { + form.reset({ + name: ruleData.name, + description: ruleData.description || "", + targetGroup: ruleData.targetGroup, + enabled: ruleData.enabled, + schedule: ruleData.schedule, + variablePattern: ruleData.variablePattern || "", + connectionIds: ruleData.connections.map((c: any) => c.id), + }); + setConditions(ruleData.conditions || []); + } + }, [ruleData, form]); + + // Create/Update rule mutation + const saveMutation = useMutation({ + mutationFn: async (data: DynamicGroupRule) => { + const url = isEditMode + ? `/api/dynamic-group-rules/${ruleId}` + : `/api/dynamic-group-rules`; + const method = isEditMode ? "PUT" : "POST"; + const response = await apiRequest(method, url, data); + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Success", + description: `Rule ${isEditMode ? "updated" : "created"} successfully`, + variant: "default", + }); + queryClient.invalidateQueries({ queryKey: ["/api/dynamic-group-rules"] }); + if (onSuccess) onSuccess(); + }, + onError: (error: Error) => { + toast({ + title: "Error", + description: `Failed to ${isEditMode ? "update" : "create"} rule: ${error.message}`, + variant: "destructive", + }); + }, + }); + + // Handle form submission + const onSubmit = (values: RuleFormValues) => { + const ruleData: DynamicGroupRule = { + ...values, + conditions, + }; + saveMutation.mutate(ruleData); + }; + + // Update conditions handler + const handleConditionsUpdate = (updatedConditions: Condition[]) => { + setConditions(updatedConditions); + }; + + // Loading state + const isLoading = isLoadingConnections || (isEditMode && isLoadingRule); + + return ( +
+ + + General + Conditions + Preview + + +
+ + + {isLoading ? ( +
+ +
+ ) : ( + + + Rule Configuration + + + {/* Basic rule information */} + ( + + Rule Name + + + + + + )} + /> + + ( + + Description + +