diff --git a/client/src/components/domain/domain-details.tsx b/client/src/components/domain/domain-details.tsx new file mode 100644 index 0000000..29994e7 --- /dev/null +++ b/client/src/components/domain/domain-details.tsx @@ -0,0 +1,957 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { DnsRecord, InsertDnsRecord, Domain, Provider, recordTypes } from "@shared/schema"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip"; +import { RecentActivity } from "@/components/activity/recent-activity"; +import { Pagination } from "@/components/shared/pagination"; +import { Loader2, FileText, Pencil, Trash2, Plus } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; + +// DNS Record form schema +const dnsRecordSchema = z.object({ + name: z.string().min(1, "Record name is required"), + type: z.enum(recordTypes), + content: z.string().min(1, "Content is required"), + ttl: z.number().int().min(1).default(3600), + proxied: z.boolean().default(false), + isActive: z.boolean().default(true), + isAutoIP: z.boolean().default(false), + notes: z.string().optional(), + providerId: z.string().uuid().optional(), +}); + +interface DomainDetailsProps { + domain: Domain; + onBack?: () => void; +} + +export function DomainDetails({ domain, onBack }: DomainDetailsProps) { + const { toast } = useToast(); + + const [isAddRecordDialogOpen, setIsAddRecordDialogOpen] = useState(false); + const [isEditRecordDialogOpen, setIsEditRecordDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedRecord, setSelectedRecord] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 5; + + // Fetch providers for the form + const { data: providers = [] } = useQuery({ + queryKey: ["/api/providers"], + }); + + // Fetch DNS Records + const { + data: records = [], + isLoading + } = useQuery({ + queryKey: ["/api/dns-records", domain.id], + queryFn: async () => { + const res = await apiRequest("GET", `/api/dns-records?domainId=${domain.id}`); + return await res.json(); + }, + enabled: !!domain?.id, + }); + + // Form for adding/editing a record + const form = useForm>({ + resolver: zodResolver(dnsRecordSchema), + defaultValues: { + name: "", + type: "A", + content: "", + ttl: 3600, + proxied: false, + isActive: true, + isAutoIP: false, + notes: "", + providerId: undefined, + }, + }); + + // Add record mutation + const addRecordMutation = useMutation({ + mutationFn: async (data: z.infer) => { + // Prepare record data + const recordData: InsertDnsRecord = { + ...data, + domainId: domain.id, + }; + + // Handle providerId - convert "none" to null + if (recordData.providerId === "none") { + recordData.providerId = null; + } + + const res = await apiRequest("POST", "/api/dns-records", recordData); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/dns-records", domain.id] }); + setIsAddRecordDialogOpen(false); + form.reset(); + toast({ + title: "Record added", + description: "The DNS record has been successfully added.", + }); + }, + onError: (error) => { + toast({ + title: "Failed to add record", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Update record mutation + const updateRecordMutation = useMutation({ + mutationFn: async (data: z.infer & { id: string }) => { + const { id, ...updateData } = data; + + // Handle providerId - convert "none" to null + if (updateData.providerId === "none") { + updateData.providerId = null; + } + + const res = await apiRequest("PUT", `/api/dns-records/${id}`, updateData); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/dns-records", domain.id] }); + setIsEditRecordDialogOpen(false); + setSelectedRecord(null); + toast({ + title: "Record updated", + description: "The DNS record has been successfully updated.", + }); + }, + onError: (error) => { + toast({ + title: "Failed to update record", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Delete record mutation + const deleteRecordMutation = useMutation({ + mutationFn: async (recordId: string) => { + await apiRequest("DELETE", `/api/dns-records/${recordId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/dns-records", domain.id] }); + setIsDeleteDialogOpen(false); + setSelectedRecord(null); + toast({ + title: "Record deleted", + description: "The DNS record has been successfully deleted.", + }); + }, + onError: (error) => { + toast({ + title: "Failed to delete record", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Form submission handlers + const onAddSubmit = (data: z.infer) => { + addRecordMutation.mutate(data); + }; + + const onEditSubmit = (data: z.infer) => { + if (selectedRecord) { + updateRecordMutation.mutate({ ...data, id: selectedRecord.id }); + } + }; + + // Handle record editing + const handleEditRecord = (record: DnsRecord) => { + setSelectedRecord(record); + + // Set form values for editing + form.reset({ + name: record.name, + type: record.type, + content: record.content, + ttl: record.ttl, + proxied: record.proxied, + isActive: record.isActive, + isAutoIP: record.isAutoIP, + notes: record.notes || "", + providerId: record.providerId || "none", + }); + + setIsEditRecordDialogOpen(true); + }; + + // Handle record deletion + const handleDeleteRecord = (record: DnsRecord) => { + setSelectedRecord(record); + setIsDeleteDialogOpen(true); + }; + + // Confirm deletion + const confirmDeleteRecord = () => { + if (selectedRecord) { + deleteRecordMutation.mutate(selectedRecord.id); + } + }; + + // Get a human-readable provider name from the provider ID + const getProviderName = (providerId: string | null) => { + if (!providerId) return "None"; + const provider = providers.find(p => p.id === providerId); + return provider ? provider.name : "Unknown Provider"; + }; + + // Calculate pagination + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedRecords = records.slice(startIndex, endIndex); + const totalPages = Math.ceil(records.length / pageSize); + + // Handle adding a new record + const handleAddRecord = () => { + // Reset form with defaults + form.reset({ + name: "", + type: "A", + content: "", + ttl: 3600, + proxied: false, + isActive: true, + isAutoIP: false, + notes: "", + providerId: "none", + }); + setIsAddRecordDialogOpen(true); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> +
+

DNS Records for {domain.name}

+ +
+ + {records.length === 0 ? ( +
+
+ +
+

No DNS records found

+

+ Get started by adding your first DNS record. +

+
+ +
+
+ ) : ( +
+
+ + + + Name + Type + Content + TTL + Provider + Last Update + Status + Actions + + + + {paginatedRecords.map(record => ( + + + {record.name === "@" ? ( + + + + {record.name} + + +

Root domain: {domain.name}

+
+
+
+ ) : ( + record.name + )} +
+ + {record.type} + + + + + + + {record.content} + + + +

{record.content}

+ {record.notes && ( + <> +
+

{record.notes}

+ + )} +
+
+
+
+ {record.ttl}s + {getProviderName(record.providerId)} + + {record.lastUpdated + ? formatDistanceToNow(new Date(record.lastUpdated), { addSuffix: true }) + : "Never"} + + + {record.isActive ? ( + Active + ) : ( + Inactive + )} + + +
+ + +
+
+
+ ))} +
+
+
+ + {records.length > pageSize && ( +
+ +
+ )} +
+ )} + +
+ + + Recent Activity + + + + + +
+ + {/* Add Record Dialog */} + + + + Add DNS Record + + Add a new DNS record to {domain.name}. + + + +
+ + ( + + Name + + + + + Use @ for root domain or enter subdomain name + + + + )} + /> + + ( + + DNS Provider + + + Select the DNS provider to use for this record + + + + )} + /> + + ( + + Record Type + + + + )} + /> + + ( + + Content + + + + + {form.watch("type") === "A" && "IP address (e.g. 192.168.1.1)"} + {form.watch("type") === "AAAA" && "IPv6 address"} + {form.watch("type") === "CNAME" && "Domain name (e.g. example.com)"} + {form.watch("type") === "MX" && "Mail server (e.g. mail.example.com)"} + {form.watch("type") === "TXT" && "Text content"} + + + + )} + /> + + ( + + TTL (seconds) + + field.onChange(parseInt(e.target.value || "3600"))} + /> + + + Time-to-live in seconds. 3600 = 1 hour + + + + )} + /> + + ( + +
+ Proxied + + Enable proxying through CDN (Cloudflare only) + +
+ + + +
+ )} + /> + + ( + +
+ Active + + Enable this record to be used + +
+ + + +
+ )} + /> + + {(form.watch("type") === "A" || form.watch("type") === "AAAA") && ( + ( + +
+ Auto IP Address + + Automatically determine IP address using STUN + +
+ + + +
+ )} + /> + )} + + ( + + Notes + + + + + Add any additional information about this record + + + + )} + /> + + + + + + +
+
+ + {/* Edit Record Dialog */} + + + + Edit DNS Record + + Update the DNS record for {domain.name}. + + + +
+ + ( + + Name + + + + + Use @ for root domain or enter subdomain name + + + + )} + /> + + ( + + DNS Provider + + + Select the DNS provider to use for this record + + + + )} + /> + + ( + + Record Type + + + + )} + /> + + ( + + Content + + + + + {form.watch("type") === "A" && "IP address (e.g. 192.168.1.1)"} + {form.watch("type") === "AAAA" && "IPv6 address"} + {form.watch("type") === "CNAME" && "Domain name (e.g. example.com)"} + {form.watch("type") === "MX" && "Mail server (e.g. mail.example.com)"} + {form.watch("type") === "TXT" && "Text content"} + + + + )} + /> + + ( + + TTL (seconds) + + field.onChange(parseInt(e.target.value || "3600"))} + /> + + + Time-to-live in seconds. 3600 = 1 hour + + + + )} + /> + + ( + +
+ Proxied + + Enable proxying through CDN (Cloudflare only) + +
+ + + +
+ )} + /> + + ( + +
+ Active + + Enable this record to be used + +
+ + + +
+ )} + /> + + {(form.watch("type") === "A" || form.watch("type") === "AAAA") && ( + ( + +
+ Auto IP Address + + Automatically determine IP address using STUN + +
+ + + +
+ )} + /> + )} + + ( + + Notes + + + + + Add any additional information about this record + + + + )} + /> + + + + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Are you sure? + + This will permanently delete the DNS record {selectedRecord?.name} of type {selectedRecord?.type}. + This action cannot be undone. + + + + Cancel + + {deleteRecordMutation.isPending ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + + + ); +} \ No newline at end of file diff --git a/client/src/pages/domains.tsx b/client/src/pages/domains.tsx index ac27a0e..8147ff8 100644 --- a/client/src/pages/domains.tsx +++ b/client/src/pages/domains.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { MainLayout } from "@/components/layouts/main-layout"; import { DomainTable } from "@/components/domain/domain-table"; +import { DomainDetails } from "@/components/domain/domain-details"; import { Domain, InsertDomain, Provider } from "@shared/schema"; import { useOrganization } from "@/context/organization-context"; import { apiRequest, queryClient } from "@/lib/queryClient"; @@ -44,7 +45,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Loader2 } from "lucide-react"; +import { Loader2, ArrowLeft } from "lucide-react"; // Domain name regex const domainRegex = /^((?!-)[A-Za-z0-9-]{1,63}(?(null); + const [activeDomain, setActiveDomain] = useState(null); // Fetch domains const { data: domains = [], isLoading } = useQuery({ @@ -156,8 +158,12 @@ export default function DomainsPage() { // Handle domain management const handleManageDomain = (domain: Domain) => { - // Navigate to DNS records for this domain - window.location.href = `/dns-records?domainId=${domain.id}`; + setActiveDomain(domain); + }; + + // Return to domain list + const handleBackToDomains = () => { + setActiveDomain(null); }; // Handle domain deletion @@ -170,6 +176,10 @@ export default function DomainsPage() { const confirmDeleteDomain = () => { if (selectedDomain) { deleteDomainMutation.mutate(selectedDomain.id); + // If the deleted domain is the active domain, return to the domain list + if (activeDomain && activeDomain.id === selectedDomain.id) { + setActiveDomain(null); + } } }; @@ -178,22 +188,42 @@ export default function DomainsPage() { title="Domains" description="Manage your DNS domains across providers." > -
- -
+ {!activeDomain ? ( + // Domain list view + <> +
+ +
- {/* Domains Table */} - setIsAddDomainDialogOpen(true)} - /> + {/* Domains Table */} + setIsAddDomainDialogOpen(true)} + /> + + ) : ( + // Domain detail view with DNS records + <> +
+ + +
+ + )} {/* Add Domain Dialog */} - + Add Domain @@ -270,7 +300,7 @@ export default function DomainsPage() { {/* Delete Confirmation Dialog */} - + Are you sure? @@ -299,4 +329,4 @@ export default function DomainsPage() { ); -} +} \ No newline at end of file