diff --git a/client/src/App.tsx b/client/src/App.tsx index cc756ab..0f7004e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,6 +19,7 @@ import LdapQueryBuilderPage from "@/pages/ldap-query-builder-page"; 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"; export default function App() { return ( @@ -42,6 +43,7 @@ export default function App() { + diff --git a/client/src/layouts/dashboard-layout.tsx b/client/src/layouts/dashboard-layout.tsx index bbdbf21..d829315 100644 --- a/client/src/layouts/dashboard-layout.tsx +++ b/client/src/layouts/dashboard-layout.tsx @@ -30,6 +30,7 @@ import { ShieldAlert, Filter, ClipboardList, + Building2, } from "lucide-react"; import { useMobile } from "@/hooks/use-mobile"; import { useToast } from "@/hooks/use-toast"; @@ -56,6 +57,7 @@ const menuSections: MenuSection[] = [ { title: "Organizational Units", path: "/organizational-units", icon: }, { title: "Computers", path: "/computers", icon: }, { title: "Domains", path: "/domains", icon: }, + { title: "Sites & Subnets", path: "/sites", icon: }, ], }, { diff --git a/client/src/pages/sites-page.tsx b/client/src/pages/sites-page.tsx new file mode 100644 index 0000000..9f8fea2 --- /dev/null +++ b/client/src/pages/sites-page.tsx @@ -0,0 +1,1077 @@ +import { useState, useEffect } from "react"; +import { useToast } from "@/hooks/use-toast"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { queryClient } from "@/lib/queryClient"; +import { useLocation } from "wouter"; +import { Button } from "@/components/ui/button"; +import { Plus, Edit, Trash2, RefreshCw, HardDrive, Network } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface AdSite { + id: number; + objectGUID: string; + name: string; + dn: string; + canonicalName: string; + description: string; + location: string; + subnets: string[] | null; + managedBy: string | null; + objectType: string; + connectionId: number; + adProperties: Record; +} + +interface AdSubnet { + id: number; + objectGUID: string; + name: string; + dn: string; + canonicalName: string; + description: string; + location: string; + siteObject: string | null; + cidr: string | null; + managedBy: string | null; + objectType: string; + connectionId: number; + adProperties: Record; +} + +const siteSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + location: z.string().optional(), +}); + +const subnetSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + location: z.string().optional(), + siteObject: z.string().optional(), + cidr: z.string().optional(), +}); + +type SiteFormValues = z.infer; +type SubnetFormValues = z.infer; + +const MAX_ITEMS_PER_PAGE = 10; + +export default function SitesPage() { + const { toast } = useToast(); + const [location, setLocation] = useLocation(); + const [selectedConnection, setSelectedConnection] = useState(null); + const [activeTab, setActiveTab] = useState("sites"); + + // Pagination state + const [currentSitePage, setCurrentSitePage] = useState(1); + const [totalSitePages, setTotalSitePages] = useState(1); + + const [currentSubnetPage, setCurrentSubnetPage] = useState(1); + const [totalSubnetPages, setTotalSubnetPages] = useState(1); + + // Editing state + const [editingSite, setEditingSite] = useState(null); + const [editingSubnet, setEditingSubnet] = useState(null); + + // Dialog state + const [isSiteFormOpen, setIsSiteFormOpen] = useState(false); + const [isSubnetFormOpen, setIsSubnetFormOpen] = useState(false); + + // Get LDAP connections + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ['/api/ldap-connections'], + }); + + // Set first connection as default when data loads + useEffect(() => { + if (connections.length > 0 && !selectedConnection) { + setSelectedConnection(connections[0].id); + } + }, [connections, selectedConnection]); + + interface ApiResponse { + data: T[]; + metadata: { + totalPages: number; + currentPage: number; + totalRecordCount: number; + }; + } + + // Get sites from the selected connection + const { + data: sitesData, + isLoading: isLoadingSites, + refetch: refetchSites, + } = useQuery>({ + queryKey: ['/api/connections', selectedConnection, '/ad-sites', { page: currentSitePage, top: MAX_ITEMS_PER_PAGE }], + enabled: !!selectedConnection, + }); + + // Get subnets from the selected connection + const { + data: subnetsData, + isLoading: isLoadingSubnets, + refetch: refetchSubnets, + } = useQuery>({ + queryKey: ['/api/connections', selectedConnection, '/ad-subnets', { page: currentSubnetPage, top: MAX_ITEMS_PER_PAGE }], + enabled: !!selectedConnection, + }); + + // Update pagination information when data changes + useEffect(() => { + if (sitesData?.metadata) { + setTotalSitePages(sitesData.metadata.totalPages || 1); + } + + if (subnetsData?.metadata) { + setTotalSubnetPages(subnetsData.metadata.totalPages || 1); + } + }, [sitesData, subnetsData]); + + // Site form setup + const siteForm = useForm({ + resolver: zodResolver(siteSchema), + defaultValues: { + name: "", + description: "", + location: "", + }, + }); + + // Subnet form setup + const subnetForm = useForm({ + resolver: zodResolver(subnetSchema), + defaultValues: { + name: "", + description: "", + location: "", + siteObject: "", + cidr: "", + }, + }); + + // Reset and populate forms when editing + useEffect(() => { + if (editingSite) { + siteForm.reset({ + name: editingSite.name, + description: editingSite.description || "", + location: editingSite.location || "", + }); + setIsSiteFormOpen(true); + } else { + siteForm.reset({ + name: "", + description: "", + location: "", + }); + } + }, [editingSite, siteForm]); + + useEffect(() => { + if (editingSubnet) { + subnetForm.reset({ + name: editingSubnet.name, + description: editingSubnet.description || "", + location: editingSubnet.location || "", + siteObject: editingSubnet.siteObject || "", + cidr: editingSubnet.cidr || "", + }); + setIsSubnetFormOpen(true); + } else { + subnetForm.reset({ + name: "", + description: "", + location: "", + siteObject: "", + cidr: "", + }); + } + }, [editingSubnet, subnetForm]); + + // Create site mutation + const createSiteMutation = useMutation({ + mutationFn: async (data: SiteFormValues) => { + const response = await fetch(`/api/connections/${selectedConnection}/ad-sites`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create site'); + } + + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Site created", + description: "The site has been created successfully.", + }); + setIsSiteFormOpen(false); + queryClient.invalidateQueries({ queryKey: ['/api/connections', selectedConnection, '/ad-sites'] }); + }, + onError: (error) => { + toast({ + title: "Error creating site", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Update site mutation + const updateSiteMutation = useMutation({ + mutationFn: async ({ objectGUID, data }: { objectGUID: string; data: SiteFormValues }) => { + const response = await fetch(`/api/connections/${selectedConnection}/ad-sites/${objectGUID}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to update site'); + } + + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Site updated", + description: "The site has been updated successfully.", + }); + setEditingSite(null); + setIsSiteFormOpen(false); + queryClient.invalidateQueries({ queryKey: ['/api/connections', selectedConnection, '/ad-sites'] }); + }, + onError: (error) => { + toast({ + title: "Error updating site", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Delete site mutation + const deleteSiteMutation = useMutation({ + mutationFn: async (objectGUID: string) => { + const response = await fetch(`/api/connections/${selectedConnection}/ad-sites/${objectGUID}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to delete site'); + } + + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Site deleted", + description: "The site has been deleted successfully.", + }); + queryClient.invalidateQueries({ queryKey: ['/api/connections', selectedConnection, '/ad-sites'] }); + }, + onError: (error) => { + toast({ + title: "Error deleting site", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Create subnet mutation + const createSubnetMutation = useMutation({ + mutationFn: async (data: SubnetFormValues) => { + const response = await fetch(`/api/connections/${selectedConnection}/ad-subnets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create subnet'); + } + + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Subnet created", + description: "The subnet has been created successfully.", + }); + setIsSubnetFormOpen(false); + queryClient.invalidateQueries({ queryKey: ['/api/connections', selectedConnection, '/ad-subnets'] }); + }, + onError: (error) => { + toast({ + title: "Error creating subnet", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Update subnet mutation + const updateSubnetMutation = useMutation({ + mutationFn: async ({ objectGUID, data }: { objectGUID: string; data: SubnetFormValues }) => { + const response = await fetch(`/api/connections/${selectedConnection}/ad-subnets/${objectGUID}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to update subnet'); + } + + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Subnet updated", + description: "The subnet has been updated successfully.", + }); + setEditingSubnet(null); + setIsSubnetFormOpen(false); + queryClient.invalidateQueries({ queryKey: ['/api/connections', selectedConnection, '/ad-subnets'] }); + }, + onError: (error) => { + toast({ + title: "Error updating subnet", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Delete subnet mutation + const deleteSubnetMutation = useMutation({ + mutationFn: async (objectGUID: string) => { + const response = await fetch(`/api/connections/${selectedConnection}/ad-subnets/${objectGUID}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to delete subnet'); + } + + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Subnet deleted", + description: "The subnet has been deleted successfully.", + }); + queryClient.invalidateQueries({ queryKey: ['/api/connections', selectedConnection, '/ad-subnets'] }); + }, + onError: (error) => { + toast({ + title: "Error deleting subnet", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Handle site form submission + const onSiteSubmit = (data: SiteFormValues) => { + if (editingSite) { + updateSiteMutation.mutate({ objectGUID: editingSite.objectGUID, data }); + } else { + createSiteMutation.mutate(data); + } + }; + + // Handle subnet form submission + const onSubnetSubmit = (data: SubnetFormValues) => { + if (editingSubnet) { + updateSubnetMutation.mutate({ objectGUID: editingSubnet.objectGUID, data }); + } else { + createSubnetMutation.mutate(data); + } + }; + + return ( +
+
+

AD Sites and Subnets

+
+ +
+
+ + + + + + Sites + + + + Subnets + + + + + + +
+ Active Directory Sites + + Manage physical locations in your Active Directory infrastructure. + +
+
+ + +
+
+ + {isLoadingSites || !selectedConnection ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ +
+ ))} +
+ ) : sitesData?.data && sitesData.data.length > 0 ? ( + + + + Name + Description + Location + Associated Subnets + Actions + + + + {sitesData?.data?.map((site: AdSite) => ( + + {site.name} + {site.description || '—'} + {site.location || '—'} + + {site.subnets && site.subnets.length > 0 + ? site.subnets.map((subnet, idx) => ( + + {subnet.split(',')[0].replace('CN=', '')} + + )) + : '—'} + + +
+ + + + + + + + + Are you sure you want to delete this site? + + + This action cannot be undone. This will permanently delete the + site "{site.name}" and all of its data from the server. + + + + Cancel + deleteSiteMutation.mutate(site.objectGUID)} + className="bg-destructive text-destructive-foreground" + > + Delete + + + + +
+
+
+ ))} +
+
+ ) : ( +
+

No sites found for this connection.

+
+ )} + + {/* Pagination for sites */} + {sitesData?.data && sitesData.data.length > 0 && totalSitePages > 1 && ( +
+ + + + + + {Array.from({ length: totalSitePages }, (_, i) => i + 1).map((page) => ( + + + + ))} + + + + + +
+ )} +
+
+ + {/* Site Form Dialog */} + + + + {editingSite ? 'Edit Site' : 'Create New Site'} + +
+ + ( + + Name* + + + + + The name of the Active Directory site. + + + + )} + /> + ( + + Description + +