diff --git a/apps/web/actions/organization/domain-utils.ts b/apps/web/actions/organization/domain-utils.ts index 4a8fcb11e5b..c6df7634d1c 100644 --- a/apps/web/actions/organization/domain-utils.ts +++ b/apps/web/actions/organization/domain-utils.ts @@ -1,3 +1,20 @@ +import { parse } from "tldts"; + +const VERCEL_CNAME_TARGET = "cname.vercel-dns.com"; +const VERCEL_A_RECORD = "76.76.21.21"; + +export const isSubdomain = (raw: string): boolean => { + const input = + raw + .trim() + .replace(/^https?:\/\//i, "") + .split("/")[0] ?? ""; + if (!input) return false; + const host = (input.replace(/\.$/, "").split(":")[0] || "").toLowerCase(); + const { subdomain } = parse(host); + return Boolean(subdomain); +}; + export const getConfigResponse = async (domain: string) => { const response = await fetch( `https://api.vercel.com/v6/domains/${domain.toLowerCase()}/config?teamId=${ @@ -67,6 +84,22 @@ export const addDomain = async (domain: string) => { return response; }; +export const removeDomain = async (domain: string) => { + const response = await fetch( + `https://api.vercel.com/v9/projects/${ + process.env.VERCEL_PROJECT_ID + }/domains/${domain.toLowerCase()}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + }, + }, + ).then((res) => res.json()); + + return response; +}; + export const getRequiredConfig = async (domain: string) => { // First try to get the records directly try { @@ -162,9 +195,28 @@ export const checkDomainStatus = async (domain: string) => { verified = verificationJson?.verified; } - // Get the current and required A records const currentAValues = configJson.aValues || []; - const requiredAValue = requiredConfigJson.aValues?.[0]; + const subdomain = isSubdomain(domain); + + // Vercel's recommendations are the source of truth, but the config + // endpoint can omit them (e.g. for domains not using Vercel DNS). Fall + // back to the well-known Vercel targets so the setup UI always has a + // record to display instead of rendering empty. + let recommendedCNAME: Array<{ rank: number; value: string }> = + configJson.recommendedCNAME || []; + let recommendedIPv4: Array<{ rank: number; value: string[] | string }> = + configJson.recommendedIPv4 || []; + + if (subdomain && recommendedCNAME.length === 0) { + recommendedCNAME = [{ rank: 0, value: VERCEL_CNAME_TARGET }]; + } + if (!subdomain && recommendedIPv4.length === 0) { + recommendedIPv4 = [{ rank: 0, value: [VERCEL_A_RECORD] }]; + } + + const requiredAValue = + requiredConfigJson.aValues?.[0] ?? + (!subdomain ? VERCEL_A_RECORD : undefined); return { verified, @@ -173,10 +225,13 @@ export const checkDomainStatus = async (domain: string) => { verification: domainJson?.verification || [], currentAValues, requiredAValue, + recommendedCNAME, + recommendedIPv4, }, status: domainJson, }; - } catch (_error) { + } catch (error) { + console.error("checkDomainStatus failed", { domain, error }); return { verified: false, error: "Failed to check domain status", diff --git a/apps/web/actions/organization/remove-domain.ts b/apps/web/actions/organization/remove-domain.ts index 5d1c051bcfa..6bc7ba186d4 100644 --- a/apps/web/actions/organization/remove-domain.ts +++ b/apps/web/actions/organization/remove-domain.ts @@ -7,6 +7,7 @@ import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireOrganizationSettingsManager } from "./authorization"; +import { removeDomain } from "./domain-utils"; export async function removeOrganizationDomain( organizationId: Organisation.OrganisationId, @@ -28,19 +29,7 @@ export async function removeOrganizationDomain( try { if (organization.customDomain) { - await fetch( - `https://api.vercel.com/v9/projects/${ - process.env.VERCEL_PROJECT_ID - }/domains/${organization.customDomain.toLowerCase()}?teamId=${ - process.env.VERCEL_TEAM_ID - }`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, - }, - }, - ); + await removeDomain(organization.customDomain); } await db() diff --git a/apps/web/actions/organization/update-domain.ts b/apps/web/actions/organization/update-domain.ts index 7ca542c9c44..5cf1cf21d25 100644 --- a/apps/web/actions/organization/update-domain.ts +++ b/apps/web/actions/organization/update-domain.ts @@ -24,6 +24,8 @@ export async function updateDomain( throw new Error("User is not subscribed"); } + const normalizedDomain = domain.trim().toLowerCase(); + const [organization] = await db() .select() .from(organizations) @@ -37,7 +39,7 @@ export async function updateDomain( const existingDomain = await db() .select() .from(organizations) - .where(eq(organizations.customDomain, domain)) + .where(eq(organizations.customDomain, normalizedDomain)) .limit(1); if (existingDomain.length > 0 && existingDomain[0]?.id !== organizationId) { @@ -45,7 +47,7 @@ export async function updateDomain( } try { - const addDomainResponse = await addDomain(domain); + const addDomainResponse = await addDomain(normalizedDomain); if (addDomainResponse.error) { throw new Error(addDomainResponse.error.message); @@ -54,12 +56,12 @@ export async function updateDomain( await db() .update(organizations) .set({ - customDomain: domain, + customDomain: normalizedDomain, domainVerified: null, }) .where(eq(organizations.id, organizationId)); - const status = await checkDomainStatus(domain); + const status = await checkDomainStatus(normalizedDomain); if (status.verified) { await db() diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx index 5c8eb5d35fa..f559bb4d190 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CustomDomainDialog/VerifyStep.tsx @@ -99,6 +99,14 @@ const VerifyStep = ({ hasRecommendedCNAME && !cnameConfigured && isSubdomain(domain); const showTXTRecord = hasTXTVerification && !isVerified; + const showFallbackRecords = + !showTXTRecord && + !showARecord && + !showCNAMERecord && + !aRecordConfigured && + !cnameConfigured; + const fallbackIsSubdomain = isSubdomain(domain); + const handleCopy = async (text: string, fieldId: string) => { try { await navigator.clipboard.writeText(text); @@ -146,6 +154,13 @@ const VerifyStep = ({
+ We couldn't load the DNS configuration for this domain. Use Check + Status to try again. +
++ {fallbackIsSubdomain + ? "CNAME Record Configuration" + : "A Record Configuration"} +
++ Add this record to your domain: +
+
+ {fallbackIsSubdomain ? domain.split(".")[0] : "@"}
+
+
+ {fallbackIsSubdomain
+ ? "cname.vercel-dns.com"
+ : "76.76.21.21"}
+
+
+