From f60c2f4fb9a5a7473d235258f28a640eabdb4191 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Oct 2025 17:25:49 -0700 Subject: [PATCH] Make refresh work --- .../settings/domains/[domainId]/layout.tsx | 32 -- .../settings/domains/[domainId]/page.tsx | 147 +++-- src/components/DNSRecordTable.tsx | 6 - src/components/DNSRecordsDataTable.tsx | 1 - src/components/DomainCertForm.tsx | 365 +++++++++++++ src/components/DomainInfoCard.tsx | 501 ++---------------- src/components/DomainsTable.tsx | 10 +- src/components/RefreshButton.tsx | 43 ++ src/components/RestartDomainButton.tsx | 66 +++ 9 files changed, 590 insertions(+), 581 deletions(-) delete mode 100644 src/app/[orgId]/settings/domains/[domainId]/layout.tsx create mode 100644 src/components/DomainCertForm.tsx create mode 100644 src/components/RefreshButton.tsx create mode 100644 src/components/RestartDomainButton.tsx diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx deleted file mode 100644 index d33d666a..00000000 --- a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { redirect } from "next/navigation"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { internal } from "@app/lib/api"; -import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { AxiosResponse } from "axios"; -import DomainProvider from "@app/providers/DomainProvider"; - -interface SettingsLayoutProps { - children: React.ReactNode; - params: Promise<{ domainId: string; orgId: string }>; -} - -export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { - const { domainId, orgId } = await params; - let domain = null; - - try { - const res = await internal.get>( - `/org/${orgId}/domain/${domainId}`, - await authCookieHeader() - ); - domain = res.data.data; - } catch { - redirect(`/${orgId}/settings/domains`); - } - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index d3c6da36..ce744c41 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,75 +1,53 @@ -"use client"; -import { useState } from "react"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { toast } from "@app/hooks/useToast"; -import { useRouter } from "next/navigation"; -import { RefreshCw } from "lucide-react"; -import { Button } from "@app/components/ui/button"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import DomainInfoCard from "@app/components/DomainInfoCard"; -import { useDomain } from "@app/contexts/domainContext"; -import { useTranslations } from "next-intl"; +import RestartDomainButton from "@app/components/RestartDomainButton"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { pullEnv } from "@app/lib/pullEnv"; +import { getTranslations } from "next-intl/server"; +import RefreshButton from "@app/components/RefreshButton"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { GetDNSRecordsResponse } from "@server/routers/domain"; +import DNSRecordsTable from "@app/components/DNSRecordTable"; +import DomainCertForm from "@app/components/DomainCertForm"; -export default function DomainSettingsPage() { - const { domain, orgId } = useDomain(); - const router = useRouter(); - const api = createApiClient(useEnvContext()); - const [isRefreshing, setIsRefreshing] = useState(false); - const [restartingDomains, setRestartingDomains] = useState>( - new Set() - ); - const t = useTranslations(); - const { env } = useEnvContext(); +interface DomainSettingsPageProps { + params: Promise<{ domainId: string; orgId: string }>; +} - const refreshData = async () => { - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } - }; +export default async function DomainSettingsPage({ + params +}: DomainSettingsPageProps) { + const { domainId, orgId } = await params; + const t = await getTranslations(); + const env = pullEnv(); - const restartDomain = async (domainId: string) => { - setRestartingDomains((prev) => new Set(prev).add(domainId)); - try { - await api.post(`/org/${orgId}/domain/${domainId}/restart`); - toast({ - title: t("success"), - description: t("domainRestartedDescription", { - fallback: "Domain verification restarted successfully" - }) - }); - refreshData(); - } catch (e) { - toast({ - title: t("error"), - description: formatAxiosError(e), - variant: "destructive" - }); - } finally { - setRestartingDomains((prev) => { - const newSet = new Set(prev); - newSet.delete(domainId); - return newSet; - }); - } - }; + let domain: GetDomainResponse | null = null; + try { + const res = await internal.get( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + } catch { + return null; + } + + let dnsRecords; + try { + const response = await internal.get( + `/org/${orgId}/domain/${domainId}/dns-records`, + await authCookieHeader() + ); + dnsRecords = response.data.data; + } catch (error) { + return null; + } if (!domain) { return null; } - const isRestarting = restartingDomains.has(domain.domainId); - return ( <>
@@ -77,32 +55,31 @@ export default function DomainSettingsPage() { title={domain.baseDomain} description={t("domainSettingDescription")} /> - {env.flags.usePangolinDns && ( - + {env.flags.usePangolinDns && domain.failed ? ( + + ) : ( + )}
- + + + + + {domain.type == "wildcard" && ( + + )}
); diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx index 55bf3ca5..37fa66c0 100644 --- a/src/components/DNSRecordTable.tsx +++ b/src/components/DNSRecordTable.tsx @@ -8,7 +8,6 @@ import CopyToClipboard from "@app/components/CopyToClipboard"; export type DNSRecordRow = { id: string; - domainId: string; recordType: string; // "NS" | "CNAME" | "A" | "TXT" baseDomain: string | null; value: string; @@ -17,15 +16,11 @@ export type DNSRecordRow = { type Props = { records: DNSRecordRow[]; - domainId: string; - isRefreshing?: boolean; type: string | null; }; export default function DNSRecordsTable({ records, - domainId, - isRefreshing, type }: Props) { const t = useTranslations(); @@ -114,7 +109,6 @@ export default function DNSRecordsTable({ ); diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx index 6350111b..418ec9f2 100644 --- a/src/components/DNSRecordsDataTable.tsx +++ b/src/components/DNSRecordsDataTable.tsx @@ -30,7 +30,6 @@ import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; import { useTranslations } from "next-intl"; import { Badge } from "./ui/badge"; import Link from "next/link"; -import { build } from "@server/build"; type TabFilter = { id: string; diff --git a/src/components/DomainCertForm.tsx b/src/components/DomainCertForm.tsx new file mode 100644 index 00000000..cb355fc5 --- /dev/null +++ b/src/components/DomainCertForm.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useDomainContext } from "@app/hooks/useDomainContext"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "./Settings"; +import { Button } from "./ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./ui/select"; +import { Input } from "./ui/input"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { toASCII } from "punycode"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { Switch } from "./ui/switch"; +import { useEffect, useState } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { GetDomainResponse } from "@server/routers/domain"; + +type DomainInfoCardProps = { + orgId?: string; + domainId?: string; + domain: GetDomainResponse; +}; + +// Helper functions for Unicode domain handling +function toPunycode(domain: string): string { + try { + const parts = toASCII(domain); + return parts; + } catch (error) { + return domain.toLowerCase(); + } +} + +function isValidDomainFormat(domain: string): boolean { + const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; + + if (!unicodeRegex.test(domain)) { + return false; + } + + const parts = domain.split("."); + for (const part of parts) { + if (part.length === 0 || part.startsWith("-") || part.endsWith("-")) { + return false; + } + if (part.length > 63) { + return false; + } + } + + if (domain.length > 253) { + return false; + } + + return true; +} + +const formSchema = z.object({ + baseDomain: z + .string() + .min(1, "Domain is required") + .refine((val) => isValidDomainFormat(val), "Invalid domain format") + .transform((val) => toPunycode(val)), + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() +}); + +type FormValues = z.infer; + +const certResolverOptions = [ + { id: "default", title: "Default" }, + { id: "custom", title: "Custom Resolver" } +]; + +export default function DomainCertForm({ + orgId, + domainId, + domain +}: DomainInfoCardProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient(useEnvContext()); + const { toast } = useToast(); + const [saveLoading, setSaveLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + baseDomain: "", + type: + build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: domain.certResolver, + preferWildcardCert: false + } + }); + + useEffect(() => { + if (domain.domainId) { + const certResolverValue = + domain.certResolver && domain.certResolver.trim() !== "" + ? domain.certResolver + : null; + + form.reset({ + baseDomain: domain.baseDomain || "", + type: + (domain.type as "ns" | "cname" | "wildcard") || "wildcard", + certResolver: certResolverValue, + preferWildcardCert: domain.preferWildcardCert || false + }); + } + }, [domain]); + + const onSubmit = async (values: FormValues) => { + if (!orgId || !domainId) { + toast({ + title: t("error"), + description: t("orgOrDomainIdMissing", { + fallback: "Organization or Domain ID is missing" + }), + variant: "destructive" + }); + return; + } + + setSaveLoading(true); + + try { + if (!values.certResolver) { + values.certResolver = null; + } + + await api.patch(`/org/${orgId}/domain/${domainId}`, { + certResolver: values.certResolver, + preferWildcardCert: values.preferWildcardCert + }); + + toast({ + title: t("success"), + description: t("domainSettingsUpdated", { + fallback: "Domain settings updated successfully" + }), + variant: "default" + }); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } finally { + setSaveLoading(false); + } + }; + + return ( + + + + + {t("domainSetting")} + + + + + +
+ + <> + ( + + + {t("certResolver")} + + + + + + + )} + /> + {form.watch("certResolver") !== null && + form.watch("certResolver") !== + "default" && ( + ( + + + + field.onChange( + e.target + .value + ) + } + /> + + + + )} + /> + )} + + {form.watch("certResolver") !== null && + form.watch("certResolver") !== + "default" && ( + ( + + +
+ + + {t( + "preferWildcardCert" + )} + +
+
+ + + {t( + "preferWildcardCertDescription" + )} + + +
+ )} + /> + )} + + + +
+
+ + + + +
+
+ ); +} diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index 0d0da84b..c2d9fe53 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -8,233 +8,20 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useDomainContext } from "@app/hooks/useDomainContext"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "./Settings"; -import { Button } from "./ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription -} from "@app/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "./ui/select"; -import { Input } from "./ui/input"; -import { useForm } from "react-hook-form"; -import z from "zod"; -import { toASCII } from "punycode"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; -import { Switch } from "./ui/switch"; -import { useEffect, useState } from "react"; -import DNSRecordsTable, { DNSRecordRow } from "./DNSRecordTable"; -import { createApiClient } from "@app/lib/api"; -import { useToast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; import { Badge } from "./ui/badge"; type DomainInfoCardProps = { - orgId?: string; - domainId?: string; + failed: boolean; + verified: boolean; + type: string | null; }; -// Helper functions for Unicode domain handling -function toPunycode(domain: string): string { - try { - const parts = toASCII(domain); - return parts; - } catch (error) { - return domain.toLowerCase(); - } -} - -function isValidDomainFormat(domain: string): boolean { - const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; - - if (!unicodeRegex.test(domain)) { - return false; - } - - const parts = domain.split("."); - for (const part of parts) { - if (part.length === 0 || part.startsWith("-") || part.endsWith("-")) { - return false; - } - if (part.length > 63) { - return false; - } - } - - if (domain.length > 253) { - return false; - } - - return true; -} - -const formSchema = z.object({ - baseDomain: z - .string() - .min(1, "Domain is required") - .refine((val) => isValidDomainFormat(val), "Invalid domain format") - .transform((val) => toPunycode(val)), - type: z.enum(["ns", "cname", "wildcard"]), - certResolver: z.string().nullable().optional(), - preferWildcardCert: z.boolean().optional() -}); - -type FormValues = z.infer; - -const certResolverOptions = [ - { id: "default", title: "Default" }, - { id: "custom", title: "Custom Resolver" } -]; - export default function DomainInfoCard({ - orgId, - domainId + failed, + verified, + type }: DomainInfoCardProps) { - const { domain, updateDomain } = useDomainContext(); const t = useTranslations(); - const { env } = useEnvContext(); - const api = createApiClient(useEnvContext()); - const { toast } = useToast(); - - const [dnsRecords, setDnsRecords] = useState([]); - const [loadingRecords, setLoadingRecords] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - const [saveLoading, setSaveLoading] = useState(false); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - baseDomain: "", - type: - build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", - certResolver: domain.certResolver, - preferWildcardCert: false - } - }); - - useEffect(() => { - if (domain.domainId) { - const certResolverValue = - domain.certResolver && domain.certResolver.trim() !== "" - ? domain.certResolver - : null; - - form.reset({ - baseDomain: domain.baseDomain || "", - type: - (domain.type as "ns" | "cname" | "wildcard") || "wildcard", - certResolver: certResolverValue, - preferWildcardCert: domain.preferWildcardCert || false - }); - } - }, [domain]); - - const fetchDNSRecords = async (showRefreshing = false) => { - if (showRefreshing) { - setIsRefreshing(true); - } else { - setLoadingRecords(true); - } - - try { - const response = await api.get<{ data: DNSRecordRow[] }>( - `/org/${orgId}/domain/${domainId}/dns-records` - ); - setDnsRecords(response.data.data); - } catch (error) { - // Only show error if records exist (not a 404) - const err = error as any; - if (err?.response?.status !== 404) { - toast({ - title: t("error"), - description: formatAxiosError(error), - variant: "destructive" - }); - } - } finally { - setLoadingRecords(false); - setIsRefreshing(false); - } - }; - - useEffect(() => { - if (domain.domainId) { - fetchDNSRecords(); - } - }, [domain.domainId]); - - const onSubmit = async (values: FormValues) => { - if (!orgId || !domainId) { - toast({ - title: t("error"), - description: t("orgOrDomainIdMissing", { - fallback: "Organization or Domain ID is missing" - }), - variant: "destructive" - }); - return; - } - - setSaveLoading(true); - - try { - if (!values.certResolver) { - values.certResolver = null; - } - - await api.patch( - `/org/${orgId}/domain/${domainId}`, - { - certResolver: values.certResolver, - preferWildcardCert: values.preferWildcardCert - } - ); - - updateDomain({ - ...domain, - certResolver: values.certResolver || null, - preferWildcardCert: values.preferWildcardCert || false - }); - - toast({ - title: t("success"), - description: t("domainSettingsUpdated", { - fallback: "Domain settings updated successfully" - }), - variant: "default" - }); - } catch (error) { - toast({ - title: t("error"), - description: formatAxiosError(error), - variant: "destructive" - }); - } finally { - setSaveLoading(false); - } - }; const getTypeDisplay = (type: string) => { switch (type) { @@ -250,243 +37,45 @@ export default function DomainInfoCard({ }; return ( - <> - - - - - {t("type")} - - - {getTypeDisplay( - domain.type ? domain.type : "" - )} - - - - - {t("status")} - - {domain.verified ? ( - domain.type === "wildcard" ? ( - - {t("manual", { - fallback: "Manual" - })} - - ) : ( - - {t("verified")} - - ) - ) : ( - - {t("pending", { fallback: "Pending" })} + + + + + {t("type")} + + + {getTypeDisplay(type ? type : "")} + + + + + {t("status")} + + {failed ? ( + + {t("failed", { fallback: "Failed" })} + + ) : verified ? ( + type === "wildcard" ? ( + + {t("manual", { + fallback: "Manual" + })} - )} - - - - - - - - - {domain.type === "wildcard" && ( - - - - - {t("domainSetting")} - - - - - -
- - <> - ( - - - {t("certResolver")} - - - - - - - )} - /> - {form.watch("certResolver") !== - null && - form.watch("certResolver") !== - "default" && ( - ( - - - - field.onChange( - e - .target - .value - ) - } - /> - - - - )} - /> - )} - - {form.watch("certResolver") !== - null && - form.watch("certResolver") !== - "default" && ( - ( - - -
- - - {t( - "preferWildcardCert" - )} - -
-
- - - {t( - "preferWildcardCertDescription" - )} - - -
- )} - /> - )} - - - -
-
- - - - -
-
- )} - + ) : ( + + {t("verified")} + + ) + ) : ( + + {t("pending", { fallback: "Pending" })} + + )} +
+
+
+
+
); } diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index ef6bd615..e562148e 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -3,7 +3,12 @@ import { ColumnDef } from "@tanstack/react-table"; import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { + ArrowRight, + ArrowUpDown, + MoreHorizontal, + RefreshCw +} from "lucide-react"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; @@ -217,6 +222,9 @@ export default function DomainsTable({ domains, orgId }: Props) { onClick={() => restartDomain(domain.domainId)} disabled={isRestarting} > + {isRestarting ? t("restarting", { fallback: "Restarting..." diff --git a/src/components/RefreshButton.tsx b/src/components/RefreshButton.tsx new file mode 100644 index 00000000..23597458 --- /dev/null +++ b/src/components/RefreshButton.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { toast } from "@app/hooks/useToast"; + +export default function RefreshButton() { + const router = useRouter(); + const [isRefreshing, setIsRefreshing] = useState(false); + const t = useTranslations(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + return ( + + ); +} diff --git a/src/components/RestartDomainButton.tsx b/src/components/RestartDomainButton.tsx new file mode 100644 index 00000000..c993748b --- /dev/null +++ b/src/components/RestartDomainButton.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useTranslations } from "next-intl"; + +interface RestartDomainButtonProps { + orgId: string; + domainId: string; +} + +export default function RestartDomainButton({ orgId, domainId }: RestartDomainButtonProps) { + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isRestarting, setIsRestarting] = useState(false); + const t = useTranslations(); + + const restartDomain = async () => { + setIsRestarting(true); + try { + await api.post(`/org/${orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully" + }) + }); + // Wait a bit before refreshing to allow the restart to take effect + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setIsRestarting(false); + } + }; + + return ( + + ); +}