Custom dns records

This commit is contained in:
Owen
2026-10-28 11:01:29 -04:00
parent 2298ceb63a
commit cd39f2ecd6
21 changed files with 2146 additions and 9 deletions
+370
View File
@@ -0,0 +1,370 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import { Switch } from "@app/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
CUSTOM_DNS_RECORD_TYPES,
CustomDnsRecordRow,
CustomDnsRecordType
} from "@server/routers/customDnsRecord/types";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const FORM_ID = "custom-dns-record-form";
const VALUE_PLACEHOLDERS: Record<CustomDnsRecordType, string> = {
A: "192.0.2.1",
AAAA: "2001:db8::1",
CNAME: "target.example.net",
MX: "mail.example.net",
NS: "ns1.example.net",
SRV: "sip.example.net",
TXT: "v=spf1 include:example.net ~all"
};
const optionalUint16 = z.number().int().min(0).max(65535).optional();
const formSchema = z.object({
type: z.enum(CUSTOM_DNS_RECORD_TYPES),
name: z.string().max(253),
value: z.string().min(1),
ttl: z.number().int().min(60).max(86400),
priority: optionalUint16,
weight: optionalUint16,
port: optionalUint16,
enabled: z.boolean()
});
type FormValues = z.infer<typeof formSchema>;
type CustomDnsRecordCredenzaProps = {
open: boolean;
setOpen: (open: boolean) => void;
orgId: string;
domainId: string;
baseDomain: string;
// null to create a new record
record: CustomDnsRecordRow | null;
onSaved: () => void;
};
export default function CustomDnsRecordCredenza({
open,
setOpen,
orgId,
domainId,
baseDomain,
record,
onSaved
}: CustomDnsRecordCredenzaProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
type: "A",
name: "",
value: "",
ttl: 300,
enabled: true
}
});
useEffect(() => {
if (!open) {
return;
}
form.reset({
type: record?.type ?? "A",
name: record?.name ?? "",
value: record?.value ?? "",
ttl: record?.ttl ?? 300,
priority: record?.priority ?? undefined,
weight: record?.weight ?? undefined,
port: record?.port ?? undefined,
enabled: record?.enabled ?? true
});
}, [open, record, form]);
const type = form.watch("type");
const needsPriority = type === "MX" || type === "SRV";
const isSrv = type === "SRV";
async function onSubmit(data: FormValues) {
setLoading(true);
const payload = {
type: data.type,
name: data.name,
value: data.value,
ttl: data.ttl,
priority: needsPriority ? (data.priority ?? null) : null,
weight: isSrv ? (data.weight ?? null) : null,
port: isSrv ? (data.port ?? null) : null,
enabled: data.enabled
};
try {
const res = record
? await api.post(
`/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`,
payload
)
: await api.put(
`/org/${orgId}/domain/${domainId}/custom-dns-record`,
payload
);
if (res.status === 200 || res.status === 201) {
toast({
title: record
? t("customDnsRecordUpdated")
: t("customDnsRecordCreated")
});
setOpen(false);
onSaved();
}
} catch (e) {
toast({
variant: "destructive",
title: t("customDnsRecordSaveError"),
description: formatAxiosError(e)
});
} finally {
setLoading(false);
}
}
const numberField = (
name: "ttl" | "priority" | "weight" | "port",
label: string
) => (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
autoComplete="off"
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(
v === "" ? undefined : Number(v)
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{record
? t("customDnsRecordEdit")
: t("customDnsRecordCreate")}
</CredenzaTitle>
<CredenzaDescription>
{t("customDnsRecordFormDescription", {
domain: baseDomain
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
id={FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("type")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{CUSTOM_DNS_RECORD_TYPES.map(
(recordType) => (
<SelectItem
key={recordType}
value={recordType}
>
{recordType}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Input
autoComplete="off"
placeholder="@"
{...field}
/>
</FormControl>
<span className="text-sm text-muted-foreground whitespace-nowrap">
.{baseDomain}
</span>
</div>
<FormDescription>
{t(
"customDnsRecordNameDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>{t("value")}</FormLabel>
<FormControl>
{type === "TXT" ? (
<Textarea
autoComplete="off"
placeholder={
VALUE_PLACEHOLDERS[type]
}
{...field}
/>
) : (
<Input
autoComplete="off"
placeholder={
VALUE_PLACEHOLDERS[type]
}
{...field}
/>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{needsPriority && (
<div className="flex gap-4">
{numberField(
"priority",
t("customDnsRecordPriority")
)}
{isSrv &&
numberField(
"weight",
t("customDnsRecordWeight")
)}
{isSrv &&
numberField(
"port",
t("customDnsRecordPort")
)}
</div>
)}
{numberField("ttl", t("TTL"))}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">
{t("enabled")}
</FormLabel>
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form={FORM_ID}
loading={loading}
disabled={loading}
>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Pencil, Plus, Trash2 } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
import { Switch } from "@app/components/ui/switch";
import CopyToClipboard from "@app/components/CopyToClipboard";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CustomDnsRecordCredenza from "@app/components/CustomDnsRecordCredenza";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { domainQueries } from "@app/lib/queries";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { CustomDnsRecordRow } from "@server/routers/customDnsRecord/types";
type Props = {
orgId: string;
domainId: string;
baseDomain: string;
};
function formatValue(record: CustomDnsRecordRow): string {
if (record.type === "MX") {
return `${record.priority} ${record.value}`;
}
if (record.type === "SRV") {
return `${record.priority} ${record.weight} ${record.port} ${record.value}`;
}
return record.value;
}
export default function CustomDnsRecordsTable({
orgId,
domainId,
baseDomain
}: Props) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const isDisabled = !isPaidUser(tierMatrix.customDnsRecords);
const { data: records = [], refetch } = useQuery(
domainQueries.listCustomDnsRecords({ orgId, domainId })
);
const [editorOpen, setEditorOpen] = useState(false);
const [editingRecord, setEditingRecord] =
useState<CustomDnsRecordRow | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deletingRecord, setDeletingRecord] =
useState<CustomDnsRecordRow | null>(null);
function openEditor(record: CustomDnsRecordRow | null) {
setEditingRecord(record);
setEditorOpen(true);
}
async function toggleEnabled(record: CustomDnsRecordRow, enabled: boolean) {
try {
await api.post(
`/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`,
{ enabled }
);
refetch();
} catch (e) {
toast({
variant: "destructive",
title: t("customDnsRecordSaveError"),
description: formatAxiosError(e)
});
}
}
async function deleteRecord() {
if (!deletingRecord) {
return;
}
try {
await api.delete(
`/org/${orgId}/domain/${domainId}/custom-dns-record/${deletingRecord.customDnsRecordId}`
);
toast({ title: t("customDnsRecordDeleted") });
setDeleteOpen(false);
setDeletingRecord(null);
refetch();
} catch (e) {
toast({
variant: "destructive",
title: t("customDnsRecordDeleteError"),
description: formatAxiosError(e)
});
}
}
return (
<>
<PaidFeaturesAlert tiers={tierMatrix.customDnsRecords} />
<Card
className={
isDisabled ? "pointer-events-none opacity-50" : undefined
}
>
<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="space-y-1">
<CardTitle>{t("customDnsRecords")}</CardTitle>
<CardDescription>
{t("customDnsRecordsDescription")}
</CardDescription>
</div>
<Button
onClick={() => openEditor(null)}
disabled={isDisabled}
>
<Plus className="h-4 w-4 mr-1" />
{t("customDnsRecordsAdd")}
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("value")}</TableHead>
<TableHead>{t("TTL")}</TableHead>
<TableHead>{t("enabled")}</TableHead>
<TableHead className="text-right">
{t("actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.length ? (
records.map((record) => (
<TableRow key={record.customDnsRecordId}>
<TableCell>
<CopyToClipboard
text={record.fullDomain}
displayText={record.fullDomain}
isLink={false}
/>
</TableCell>
<TableCell>{record.type}</TableCell>
<TableCell className="max-w-md break-all">
{formatValue(record)}
</TableCell>
<TableCell>{record.ttl}</TableCell>
<TableCell>
<Switch
checked={record.enabled}
disabled={isDisabled}
onCheckedChange={(checked) =>
toggleEnabled(
record,
checked
)
}
/>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
aria-label={t("edit")}
disabled={isDisabled}
onClick={() =>
openEditor(record)
}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("delete")}
disabled={isDisabled}
onClick={() => {
setDeletingRecord(record);
setDeleteOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={6}
className="h-24 text-center"
>
{t("customDnsRecordsEmpty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CustomDnsRecordCredenza
open={editorOpen && !isDisabled}
setOpen={setEditorOpen}
orgId={orgId}
domainId={domainId}
baseDomain={baseDomain}
record={editingRecord}
onSaved={() => refetch()}
/>
{deletingRecord && !isDisabled && (
<ConfirmDeleteDialog
open={deleteOpen}
setOpen={(open) => {
setDeleteOpen(open);
if (!open) {
setDeletingRecord(null);
}
}}
string={deletingRecord.fullDomain}
title={t("customDnsRecordDeleteTitle")}
dialog={
<p>
{t("customDnsRecordDeleteQuestion", {
type: deletingRecord.type,
name: deletingRecord.fullDomain
})}
</p>
}
buttonText={t("customDnsRecordDeleteConfirm")}
onConfirm={deleteRecord}
/>
)}
</>
);
}
+12
View File
@@ -10,6 +10,7 @@ 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";
@@ -97,6 +98,17 @@ export default function DomainPageClient({
type={domain.type}
/>
{build !== "oss" &&
env.flags.usePangolinDns &&
!env.flags.disableEnterpriseFeatures &&
domain.type === "ns" && (
<CustomDnsRecordsTable
orgId={orgId}
domainId={domain.domainId}
baseDomain={domain.baseDomain}
/>
)}
{domain.type === "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
+5 -1
View File
@@ -47,7 +47,11 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
[t("actionRestartOrgDomain")]: "restartOrgDomain",
[t("actionListCustomDnsRecords")]: "listCustomDnsRecords",
[t("actionCreateCustomDnsRecord")]: "createCustomDnsRecord",
[t("actionUpdateCustomDnsRecord")]: "updateCustomDnsRecord",
[t("actionDeleteCustomDnsRecord")]: "deleteCustomDnsRecord"
},
Redirect: {
+25
View File
@@ -43,6 +43,7 @@ import type {
ListDomainsResponse
} from "@server/routers/domain";
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import type { ListCustomDnsRecordsResponse } from "@server/routers/customDnsRecord/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
@@ -1878,6 +1879,30 @@ export const domainQueries = {
return res.data.data;
},
refetchInterval: durationToMs(10, "seconds")
}),
listCustomDnsRecords: ({
orgId,
domainId
}: {
orgId: string;
domainId: string;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"DOMAIN",
domainId,
"CUSTOM_DNS_RECORDS"
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListCustomDnsRecordsResponse>
>(`/org/${orgId}/domain/${domainId}/custom-dns-records`, {
signal
});
return res.data.data.records;
}
})
};