diff --git a/messages/en-US.json b/messages/en-US.json index f6e74b900..e14d1bfbc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3741,6 +3741,11 @@ "TTL": "TTL", "howToAddRecords": "How to Add Records", "dnsRecord": "DNS Records", + "domainTabSetup": "Setup", + "domainTabRecords": "Records", + "domainSetupRecordsDescription": "Create these records at your domain registrar or current DNS provider. Pangolin uses them to verify the domain and route its traffic.", + "domainRecordsInfoTitle": "Records served by Pangolin", + "domainRecordsInfoDescription": "Pangolin's nameservers answer DNS queries for {domain}. Records you add here are served directly from Pangolin, so you don't need to create them at your registrar.", "customDnsRecords": "Custom DNS Records", "customDnsRecordsAdd": "Add Record", "customDnsRecordsSearch": "Search records...", diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx new file mode 100644 index 000000000..f896d71fd --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -0,0 +1,64 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import DomainHeader from "@app/components/DomainHeader"; +import { pullEnv } from "@app/lib/pullEnv"; +import { supportsCustomDnsRecords } from "@app/lib/customDnsRecords"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Domain" +}; + +export const dynamic = "force-dynamic"; + +interface DomainLayoutProps { + children: React.ReactNode; + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function DomainLayout(props: DomainLayoutProps) { + const { domainId, orgId } = await props.params; + const t = await getTranslations(); + const env = pullEnv(); + + let domain: GetDomainResponse | null = null; + try { + const res = await internal.get>( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + } catch { + redirect(`/${orgId}/settings/domains`); + } + + if (!domain) { + redirect(`/${orgId}/settings/domains`); + } + + const navItems = [ + { + title: t("domainTabSetup"), + href: `/{orgId}/settings/domains/{domainId}/setup` + } + ]; + + if (supportsCustomDnsRecords(env, domain.type)) { + navItems.push({ + title: t("domainTabRecords"), + href: `/{orgId}/settings/domains/{domainId}/records` + }); + } + + return ( + <> + + {props.children} + + ); +} diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 9f9878967..5d0333bda 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,55 +1,8 @@ -import DomainPageClient from "@app/components/DomainPageClient"; -import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { GetDNSRecordsResponse } from "@server/routers/domain"; -import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -export const metadata: Metadata = { - title: "Domain" -}; - -interface DomainSettingsPageProps { +export default async function DomainPage(props: { params: Promise<{ domainId: string; orgId: string }>; +}) { + const { domainId, orgId } = await props.params; + redirect(`/${orgId}/settings/domains/${domainId}/setup`); } - -export default async function DomainSettingsPage({ - params -}: DomainSettingsPageProps) { - const { domainId, orgId } = await params; - - 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: GetDNSRecordsResponse | null = null; - try { - const response = await internal.get( - `/org/${orgId}/domain/${domainId}/dns-records`, - await authCookieHeader() - ); - dnsRecords = response.data.data; - } catch { - return null; - } - - if (!domain || !dnsRecords) { - return null; - } - - return ( - - ); -} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/[domainId]/records/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/records/page.tsx new file mode 100644 index 000000000..42fb80cb0 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/records/page.tsx @@ -0,0 +1,44 @@ +import DomainRecordsClient from "@app/components/DomainRecordsClient"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { pullEnv } from "@app/lib/pullEnv"; +import { supportsCustomDnsRecords } from "@app/lib/customDnsRecords"; +import { redirect } from "next/navigation"; + +interface DomainRecordsPageProps { + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function DomainRecordsPage({ + params +}: DomainRecordsPageProps) { + const { domainId, orgId } = await params; + + let domain: GetDomainResponse | null = null; + try { + const res = await internal.get( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + } catch { + return null; + } + + if (!domain) { + return null; + } + + if (!supportsCustomDnsRecords(pullEnv(), domain.type)) { + redirect(`/${orgId}/settings/domains/${domainId}/setup`); + } + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/domains/[domainId]/setup/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/setup/page.tsx new file mode 100644 index 000000000..9f83ac436 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/setup/page.tsx @@ -0,0 +1,50 @@ +import DomainSetupClient from "@app/components/DomainSetupClient"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { GetDNSRecordsResponse } from "@server/routers/domain"; + +interface DomainSetupPageProps { + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function DomainSetupPage({ + params +}: DomainSetupPageProps) { + const { domainId, orgId } = await params; + + 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: GetDNSRecordsResponse | null = null; + try { + const response = await internal.get( + `/org/${orgId}/domain/${domainId}/dns-records`, + await authCookieHeader() + ); + dnsRecords = response.data.data; + } catch { + return null; + } + + if (!domain || !dnsRecords) { + return null; + } + + return ( + + ); +} diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx index 4e7839000..2af00a78b 100644 --- a/src/components/DNSRecordsDataTable.tsx +++ b/src/components/DNSRecordsDataTable.tsx @@ -106,9 +106,18 @@ export function DNSRecordsDataTable({
-
-

{t("dnsRecord")}

- {t("required")} +
+
+

+ {t("dnsRecord")} +

+ + {t("required")} + +
+

+ {t("domainSetupRecordsDescription")} +

{ + queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "DOMAIN", domainId] + }); + }; + + return ( +
+ + {domain.baseDomain} + {domain.configManaged && ( + + + {t("configManaged", { + fallback: "Config Managed" + })} + + )} + + } + description={t("domainSettingDescription")} + /> + {env.flags.usePangolinDns && domain.failed ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/DomainPageClient.tsx b/src/components/DomainPageClient.tsx deleted file mode 100644 index 8488bc220..000000000 --- a/src/components/DomainPageClient.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { domainQueries } from "@app/lib/queries"; -import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { GetDNSRecordsResponse } from "@server/routers/domain"; -import DomainInfoCard from "@app/components/DomainInfoCard"; -import DNSRecordsTable from "@app/components/DNSRecordTable"; -import RestartDomainButton from "@app/components/RestartDomainButton"; -import RefreshButton from "@app/components/RefreshButton"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainCertForm from "@app/components/DomainCertForm"; -import CustomDnsRecordsTable from "@app/components/CustomDnsRecordsTable"; -import { build } from "@server/build"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; -import { Lock } from "lucide-react"; -import { Badge } from "@app/components/ui/badge"; - -interface DomainPageClientProps { - initialDomain: GetDomainResponse; - initialDnsRecords: GetDNSRecordsResponse; - orgId: string; - domainId: string; -} - -export default function DomainPageClient({ - initialDomain, - initialDnsRecords, - orgId, - domainId -}: DomainPageClientProps) { - const t = useTranslations(); - const { env } = useEnvContext(); - - const { data: domain, refetch: refetchDomain } = useQuery({ - ...domainQueries.getDomain({ orgId, domainId }), - initialData: initialDomain - }); - - const { data: dnsRecords, refetch: refetchDnsRecords } = useQuery({ - ...domainQueries.getDNSRecords({ orgId, domainId }), - initialData: initialDnsRecords - }); - - const refetchAll = () => { - refetchDomain(); - refetchDnsRecords(); - }; - - return ( - <> -
- - {domain.baseDomain} - {domain.configManaged && ( - - - {t("configManaged", { - fallback: "Config Managed" - })} - - )} - - } - description={t("domainSettingDescription")} - /> - {env.flags.usePangolinDns && domain.failed ? ( - - ) : ( - - )} -
-
- {build !== "oss" && env.flags.usePangolinDns ? ( - - ) : null} - - ({ - ...r, - id: String(r.id) - }))} - type={domain.type} - /> - - {build !== "oss" && - env.flags.usePangolinDns && - !env.flags.disableEnterpriseFeatures && - domain.type === "ns" && ( - - )} - - {domain.type === "wildcard" && !domain.configManaged && ( - - )} -
- - ); -} diff --git a/src/components/DomainRecordsClient.tsx b/src/components/DomainRecordsClient.tsx new file mode 100644 index 000000000..b447a254f --- /dev/null +++ b/src/components/DomainRecordsClient.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import CustomDnsRecordsTable from "@app/components/CustomDnsRecordsTable"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { useTranslations } from "next-intl"; +import { InfoIcon } from "lucide-react"; + +interface DomainRecordsClientProps { + initialDomain: GetDomainResponse; + orgId: string; + domainId: string; +} + +export default function DomainRecordsClient({ + initialDomain, + orgId, + domainId +}: DomainRecordsClientProps) { + const t = useTranslations(); + + const { data: domain } = useQuery({ + ...domainQueries.getDomain({ orgId, domainId }), + initialData: initialDomain + }); + + return ( + <> + + + {t("domainRecordsInfoTitle")} + + {t("domainRecordsInfoDescription", { + domain: domain.baseDomain + })} + + + + + + ); +} diff --git a/src/components/DomainSetupClient.tsx b/src/components/DomainSetupClient.tsx new file mode 100644 index 000000000..90c543c18 --- /dev/null +++ b/src/components/DomainSetupClient.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { GetDNSRecordsResponse } from "@server/routers/domain"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import DNSRecordsTable from "@app/components/DNSRecordTable"; +import DomainCertForm from "@app/components/DomainCertForm"; +import { build } from "@server/build"; +import { useEnvContext } from "@app/hooks/useEnvContext"; + +interface DomainSetupClientProps { + initialDomain: GetDomainResponse; + initialDnsRecords: GetDNSRecordsResponse; + orgId: string; + domainId: string; +} + +export default function DomainSetupClient({ + initialDomain, + initialDnsRecords, + orgId, + domainId +}: DomainSetupClientProps) { + const { env } = useEnvContext(); + + const { data: domain } = useQuery({ + ...domainQueries.getDomain({ orgId, domainId }), + initialData: initialDomain + }); + + const { data: dnsRecords } = useQuery({ + ...domainQueries.getDNSRecords({ orgId, domainId }), + initialData: initialDnsRecords + }); + + return ( + <> + {build !== "oss" && env.flags.usePangolinDns ? ( + + ) : null} + + ({ + ...r, + id: String(r.id) + }))} + type={domain.type} + /> + + {domain.type === "wildcard" && !domain.configManaged && ( + + )} + + ); +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 6c91dbfef..0fe9fabbe 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -44,6 +44,7 @@ export function HorizontalTabs({ .replace("{orgId}", params.orgId as string) .replace("{resourceId}", params.resourceId as string) .replace("{niceId}", params.niceId as string) + .replace("{domainId}", params.domainId as string) .replace("{userId}", params.userId as string) .replace("{clientId}", params.clientId as string) .replace("{apiKeyId}", params.apiKeyId as string) diff --git a/src/lib/customDnsRecords.ts b/src/lib/customDnsRecords.ts new file mode 100644 index 000000000..d70876285 --- /dev/null +++ b/src/lib/customDnsRecords.ts @@ -0,0 +1,16 @@ +import { build } from "@server/build"; +import { Env } from "./types/env"; + +// Custom records are only served by Pangolin's own nameservers, so they only +// apply to NS-delegated domains on deployments running Pangolin DNS +export function supportsCustomDnsRecords( + env: Env, + domainType: string | null +): boolean { + return ( + build !== "oss" && + env.flags.usePangolinDns && + !env.flags.disableEnterpriseFeatures && + domainType === "ns" + ); +}