import { useEffect, useMemo, useState } from "react" import { credentialsApi } from "@/api/credentials" import { dnsApi } from "@/api/dns" import type { DtoCreateDomainRequest, DtoCreateRecordSetRequest, DtoCredentialOut, DtoDomainResponse, DtoRecordSetResponse, DtoUpdateDomainRequest, DtoUpdateRecordSetRequest, } from "@/sdk" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { AlertTriangle, CheckCircle2, Circle, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2, } from "lucide-react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Textarea } from "@/components/ui/textarea" // ---------- helpers ---------- const statusIcon = (s?: string) => { switch (s) { case "ready": return case "provisioning": return case "failed": return default: return } } const StatusBadge = ({ s }: { s?: string }) => ( {statusIcon(s)} {s ?? "pending"} ) const parseCommaList = (v: string) => v .split(",") .map((s) => s.trim()) .filter(Boolean) const joinCommaList = (arr?: string[] | null) => (arr && arr.length ? arr.join(",") : "") const rrtypes = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"] const isR53 = (c: DtoCredentialOut) => c.credential_provider === "aws" && c.scope_kind === "service" && (() => { const s = (c as any).scope try { const obj = typeof s === "string" ? JSON.parse(s) : s || {} return obj?.service === "route53" } catch { return false } })() const credLabel = (c: DtoCredentialOut) => { const bits = [c.name || "Unnamed", c.account_id, c.region].filter(Boolean) return bits.join(" · ") } // ---------- zod schemas ---------- // IMPORTANT (Zod v4): // - `.partial()` cannot be used on object schemas containing refinements/effects. // Your schemas contain effects via `.transform(...)` and refinements via `.superRefine(...)` / `.refine(...)`, // so we define UPDATE schemas explicitly instead of using `.partial()`. const createDomainSchema = z.object({ domain_name: z .string() .min(1, "Domain is required") .max(253) .transform((s) => s.trim().replace(/\.$/, "").toLowerCase()), credential_id: z.string().uuid("Pick a credential"), zone_id: z .string() .optional() .or(z.literal("")) .transform((v) => (v ? v.trim() : undefined)), }) type CreateDomainValues = z.input // Update: all optional; replicate the normalization safely const updateDomainSchema = z.object({ domain_name: z .string() .min(1, "Domain is required") .max(253) .transform((s) => s.trim().replace(/\.$/, "").toLowerCase()) .optional(), credential_id: z.string().uuid("Pick a credential").optional(), zone_id: z .string() .optional() .or(z.literal("")) .transform((v) => (v ? v.trim() : undefined)), }) type UpdateDomainValues = z.input const ttlSchema = z .union([ z.number(), z .string() .regex(/^\d+$/) .transform((s) => Number(s)), ]) .optional() .refine((v) => v === undefined || (v >= 1 && v <= 86400), { message: "TTL must be between 1 and 86400", }) const createRecordSchema = z .object({ name: z .string() .min(1, "Name required") .max(253) .transform((s) => s.trim().replace(/\.$/, "").toLowerCase()), type: z.enum(rrtypes as [string, ...string[]]), ttl: ttlSchema, valuesCsv: z.string().optional(), }) .superRefine((vals, ctx) => { const arr = parseCommaList(vals.valuesCsv ?? "") if (arr.length === 0) { ctx.addIssue({ code: "custom", message: "At least one value is required" }) } if (vals.type === "CNAME" && arr.length !== 1) { ctx.addIssue({ code: "custom", message: "CNAME requires exactly one value" }) } }) type CreateRecordValues = z.input // Update: all optional. Only enforce "values required"/"CNAME exactly one" if valuesCsv is present. // Only validate ttl if present (ttlSchema already optional). const updateRecordSchema = z .object({ name: z .string() .min(1, "Name required") .max(253) .transform((s) => s.trim().replace(/\.$/, "").toLowerCase()) .optional(), type: z.enum(rrtypes as [string, ...string[]]).optional(), ttl: ttlSchema, valuesCsv: z.string().optional(), }) .superRefine((vals, ctx) => { const hasValues = typeof vals.valuesCsv !== "undefined" if (!hasValues) return const arr = parseCommaList(vals.valuesCsv ?? "") if (arr.length === 0) { ctx.addIssue({ code: "custom", path: ["valuesCsv"], message: "At least one value is required", }) } // We can only enforce CNAME rule if `type` is provided in patch (or you can enforce always if you want). if (vals.type === "CNAME" && arr.length !== 1) { ctx.addIssue({ code: "custom", path: ["valuesCsv"], message: "CNAME requires exactly one value", }) } }) type UpdateRecordValues = z.input // ---------- main ---------- export const DnsPage = () => { const [filter, setFilter] = useState("") const [selected, setSelected] = useState(null) const [createDomOpen, setCreateDomOpen] = useState(false) const [editDomOpen, setEditDomOpen] = useState(false) const [createRecOpen, setCreateRecOpen] = useState(false) const [editRecOpen, setEditRecOpen] = useState(false) const [editingRecord, setEditingRecord] = useState(null) const qc = useQueryClient() // ---- queries ---- const domainsQ = useQuery({ queryKey: ["dns", "domains"], queryFn: () => dnsApi.listDomains(), }) const recordsQ = useQuery({ queryKey: ["dns", "records", selected?.id], queryFn: async () => { if (!selected) return [] return await dnsApi.listRecordSetsByDomain(selected.id as string) }, enabled: !!selected?.id, }) const credentialQ = useQuery({ queryKey: ["credentials", "r53"], queryFn: () => credentialsApi.listCredentials(), }) const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data]) useEffect(() => { if (!selected && domainsQ.data && domainsQ.data.length) { setSelected(domainsQ.data[0]!) } }, [domainsQ.data, selected]) const filteredDomains = useMemo(() => { const list: DtoDomainResponse[] = domainsQ.data ?? [] if (!filter.trim()) return list const f = filter.toLowerCase() return list.filter((d) => [d.domain_name, d.zone_id, d.status] .filter(Boolean) .map((x) => String(x).toLowerCase()) .some((s) => s.includes(f)) ) }, [domainsQ.data, filter]) // ---- mutations: domains ---- const createDomainForm = useForm({ resolver: zodResolver(createDomainSchema), defaultValues: { domain_name: "", credential_id: "", zone_id: "", }, }) const createDomainMut = useMutation({ mutationFn: (v: CreateDomainValues) => dnsApi.createDomain(v as unknown as DtoCreateDomainRequest), onSuccess: async (d) => { toast.success("Domain created") setCreateDomOpen(false) createDomainForm.reset() await qc.invalidateQueries({ queryKey: ["dns", "domains"] }) setSelected(d as DtoDomainResponse) }, onError: (e: any) => toast.error("Failed to create domain", { description: e?.message ?? "Unknown error" }), }) const editDomainForm = useForm({ resolver: zodResolver(updateDomainSchema), defaultValues: {}, }) const openEditDomain = (d: DtoDomainResponse) => { setSelected(d) editDomainForm.reset({ domain_name: d.domain_name, credential_id: d.credential_id, zone_id: d.zone_id || "", }) setEditDomOpen(true) } // Build PATCH body (don’t send empty strings) const buildUpdateDomainBody = (vals: UpdateDomainValues): DtoUpdateDomainRequest => { const body: any = {} if (typeof vals.domain_name !== "undefined") body.domain_name = vals.domain_name if (typeof vals.credential_id !== "undefined" && vals.credential_id !== "") body.credential_id = vals.credential_id if (typeof vals.zone_id !== "undefined" && vals.zone_id !== "") body.zone_id = vals.zone_id return body as DtoUpdateDomainRequest } const updateDomainMut = useMutation({ mutationFn: (vals: UpdateDomainValues) => { if (!selected) throw new Error("No domain selected") return dnsApi.updateDomain(selected.id!, buildUpdateDomainBody(vals)) }, onSuccess: async () => { toast.success("Domain updated") setEditDomOpen(false) await qc.invalidateQueries({ queryKey: ["dns", "domains"] }) await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to update domain", { description: e?.message ?? "Unknown error" }), }) const deleteDomainMut = useMutation({ mutationFn: (id: string) => dnsApi.deleteDomain(id), onSuccess: async () => { toast.success("Domain deleted") await qc.invalidateQueries({ queryKey: ["dns", "domains"] }) setSelected(null) }, onError: (e: any) => toast.error("Failed to delete domain", { description: e?.message ?? "Unknown error" }), }) // ---- mutations: record sets ---- const createRecForm = useForm({ resolver: zodResolver(createRecordSchema), defaultValues: { name: "", type: "A", ttl: 300, valuesCsv: "", }, }) const explainError = (e: any) => { const msg: string = e?.response?.data?.error || e?.message || "Unknown error" if (msg.includes("ownership_conflict")) { return "Ownership conflict: this (name,type) exists but isn’t owned by autoglue." } if (msg.includes("already_exists")) { return "A record with this (name,type) already exists. Use Edit instead." } return msg } const createRecordMut = useMutation({ mutationFn: async (vals: CreateRecordValues) => { if (!selected) throw new Error("No domain selected") const body: DtoCreateRecordSetRequest = { name: vals.name, type: vals.type, ...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}), values: parseCommaList(vals.valuesCsv ?? ""), } return dnsApi.createRecordSetsByDomain(selected.id!, body) }, onSuccess: async () => { toast.success("Record set created") setCreateRecOpen(false) createRecForm.reset() await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to create record set", { description: explainError(e) }), }) const editRecForm = useForm({ resolver: zodResolver(updateRecordSchema), defaultValues: {}, }) const openEditRecord = (r: DtoRecordSetResponse) => { setEditingRecord(r) const values = (r.values as any) || [] editRecForm.reset({ name: r.name, type: r.type, ttl: r.ttl ? Number(r.ttl) : undefined, valuesCsv: joinCommaList(values), }) setEditRecOpen(true) } const updateRecordMut = useMutation({ mutationFn: async (vals: UpdateRecordValues) => { if (!editingRecord) throw new Error("No record selected") const body: DtoUpdateRecordSetRequest = {} if (typeof vals.name !== "undefined") body.name = vals.name if (typeof vals.type !== "undefined") body.type = vals.type if (typeof vals.ttl !== "undefined") body.ttl = vals.ttl as unknown as number | undefined if (typeof vals.valuesCsv !== "undefined") body.values = parseCommaList(vals.valuesCsv) return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body) }, onSuccess: async () => { toast.success("Record set updated") setEditRecOpen(false) setEditingRecord(null) await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to update record set", { description: explainError(e) }), }) const deleteRecordMut = useMutation({ mutationFn: (id: string) => dnsApi.deleteRecordSetsByDomain(id), onSuccess: async () => { toast.success("Record set deleted") await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to delete record set", { description: e?.message ?? "Unknown error" }), }) // ---------- UI ---------- return (

DNS

setFilter(e.target.value)} placeholder="Search domains…" className="w-64 pl-8" />
Add Domain
createDomainMut.mutate(v))} > ( Domain )} /> {/* CREDENTIAL SELECT (Create) */} ( Route53 Credential {credentialQ.error && (

Failed to load credentials.

)}
)} /> ( Zone ID (optional) )} />
{/* domains panel */}
Domains
{domainsQ.isFetching && }
{(filteredDomains ?? []).map((d) => ( setSelected(d)} > ))} {(!filteredDomains || filteredDomains.length === 0) && ( )}
Domain Zone Status Actions
{d.domain_name} {d.zone_id || "—"}
Delete “{d.domain_name}”? This deletes the domain metadata. External DNS records are not touched. Cancel deleteDomainMut.mutate(d.id!)} > Delete
No domains yet.
{/* records panel */}
Records {selected ? `— ${selected.domain_name}` : ""}
Add Record
createRecordMut.mutate(v))} >
( Name )} /> ( Type )} /> ( TTL (sec, optional) field.onChange( e.target.value === "" ? undefined : Number(e.target.value) ) } placeholder="300" /> )} />
( Values (comma-separated)