Split into tabs

This commit is contained in:
Owen
2026-10-28 16:32:43 -04:00
parent eea1159f40
commit 44a9e87a62
12 changed files with 385 additions and 178 deletions
+5
View File
@@ -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...",
@@ -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<AxiosResponse<GetDomainResponse>>(
`/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 (
<>
<DomainHeader initialDomain={domain} orgId={orgId} />
<HorizontalTabs items={navItems}>{props.children}</HorizontalTabs>
</>
);
}
@@ -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 (
<DomainPageClient
initialDomain={domain}
initialDnsRecords={dnsRecords}
orgId={orgId}
domainId={domainId}
/>
);
}
@@ -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 (
<DomainRecordsClient
initialDomain={domain}
orgId={orgId}
domainId={domainId}
/>
);
}
@@ -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 (
<DomainSetupClient
initialDomain={domain}
initialDnsRecords={dnsRecords}
orgId={orgId}
domainId={domainId}
/>
);
}
+12 -3
View File
@@ -106,9 +106,18 @@ export function DNSRecordsDataTable<TData, TValue>({
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2 justify-between">
<div className="relative w-full sm:max-w-sm flex flex-row gap-4 items-center">
<h1 className="font-semibold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge>
<div className="relative w-full space-y-1">
<div className="flex flex-row gap-4 items-center">
<h1 className="font-semibold">
{t("dnsRecord")}
</h1>
<Badge variant="secondary">
{t("required")}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{t("domainSetupRecordsDescription")}
</p>
</div>
<Link
href="https://docs.pangolin.net/manage/domains"
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { domainQueries } from "@app/lib/queries";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import RestartDomainButton from "@app/components/RestartDomainButton";
import RefreshButton from "@app/components/RefreshButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Lock } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
interface DomainHeaderProps {
initialDomain: GetDomainResponse;
orgId: string;
}
export default function DomainHeader({
initialDomain,
orgId
}: DomainHeaderProps) {
const t = useTranslations();
const { env } = useEnvContext();
const queryClient = useQueryClient();
const domainId = initialDomain.domainId;
const { data: domain } = useQuery({
...domainQueries.getDomain({ orgId, domainId }),
initialData: initialDomain
});
// Every domain query shares this key prefix, so this refreshes whichever
// tab is showing
const refetchAll = () => {
queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "DOMAIN", domainId]
});
};
return (
<div className="flex justify-between">
<SettingsSectionTitle
title={
<span className="flex items-center gap-2">
{domain.baseDomain}
{domain.configManaged && (
<Badge
variant="secondary"
className="flex items-center gap-1 text-sm font-normal"
>
<Lock className="h-3 w-3" />
{t("configManaged", {
fallback: "Config Managed"
})}
</Badge>
)}
</span>
}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
onSuccess={refetchAll}
/>
) : (
<RefreshButton onRefresh={refetchAll} />
)}
</div>
);
}
-123
View File
@@ -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 (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={
<span className="flex items-center gap-2">
{domain.baseDomain}
{domain.configManaged && (
<Badge
variant="secondary"
className="flex items-center gap-1 text-sm font-normal"
>
<Lock className="h-3 w-3" />
{t("configManaged", {
fallback: "Config Managed"
})}
</Badge>
)}
</span>
}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
onSuccess={refetchAll}
/>
) : (
<RefreshButton onRefresh={refetchAll} />
)}
</div>
<div className="space-y-6">
{build !== "oss" && env.flags.usePangolinDns ? (
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable
records={dnsRecords.map((r) => ({
...r,
id: String(r.id)
}))}
type={domain.type}
/>
{build !== "oss" &&
env.flags.usePangolinDns &&
!env.flags.disableEnterpriseFeatures &&
domain.type === "ns" && (
<CustomDnsRecordsTable
orgId={orgId}
domainId={domain.domainId}
baseDomain={domain.baseDomain}
verified={domain.verified}
/>
)}
{domain.type === "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</div>
</>
);
}
+49
View File
@@ -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 (
<>
<Alert variant="info">
<InfoIcon className="h-4 w-4" />
<AlertTitle>{t("domainRecordsInfoTitle")}</AlertTitle>
<AlertDescription>
{t("domainRecordsInfoDescription", {
domain: domain.baseDomain
})}
</AlertDescription>
</Alert>
<CustomDnsRecordsTable
orgId={orgId}
domainId={domain.domainId}
baseDomain={domain.baseDomain}
verified={domain.verified}
/>
</>
);
}
+66
View File
@@ -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 ? (
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable
records={dnsRecords.map((r) => ({
...r,
id: String(r.id)
}))}
type={domain.type}
/>
{domain.type === "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</>
);
}
+1
View File
@@ -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)
+16
View File
@@ -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"
);
}