diff --git a/apps/builder/app/builder/features/topbar/add-domain.tsx b/apps/builder/app/builder/features/topbar/add-domain.tsx index e5575c5ee3da..5b928280454e 100644 --- a/apps/builder/app/builder/features/topbar/add-domain.tsx +++ b/apps/builder/app/builder/features/topbar/add-domain.tsx @@ -14,6 +14,7 @@ import type { Project } from "@webstudio-is/project"; import { useId, useOptimistic, useRef, useState } from "react"; import { TerminalIcon } from "@webstudio-is/icons"; import { nativeClient } from "~/shared/trpc/trpc-client"; +import { extractCname } from "./cname"; type DomainsAddProps = { projectId: Project["id"]; @@ -38,7 +39,7 @@ export const AddDomain = ({ // Will be automatically reset on action end setIsPendingOptimistic(true); - const domain = formData.get("domain")?.toString() ?? ""; + let domain = formData.get("domain")?.toString() ?? ""; const validationResult = validateDomain(domain); if (validationResult.success === false) { @@ -46,6 +47,18 @@ export const AddDomain = ({ return; } + // detect provider only when root domain is specified + if (extractCname(domain) === "@") { + const registrar = await nativeClient.domain.findDomainRegistrar.query({ + domain, + }); + // enforce www subdomain when no support for cname flattening + // and root cname can conflict with MX or NS + if (!registrar.cnameFlattening) { + domain = `www.${domain}`; + } + } + const result = await nativeClient.domain.create.mutate({ domain, projectId, diff --git a/apps/builder/app/builder/features/topbar/domain-checkbox.tsx b/apps/builder/app/builder/features/topbar/domain-checkbox.tsx index c68aeae817ca..849a3ff339f8 100644 --- a/apps/builder/app/builder/features/topbar/domain-checkbox.tsx +++ b/apps/builder/app/builder/features/topbar/domain-checkbox.tsx @@ -20,7 +20,7 @@ interface DomainCheckboxProps { disabled?: boolean; } -const DomainCheckbox = (props: DomainCheckboxProps) => { +export const DomainCheckbox = (props: DomainCheckboxProps) => { const hasProPlan = useStore($userPlanFeatures).hasProPlan; const project = useStore($project); @@ -78,5 +78,3 @@ const DomainCheckbox = (props: DomainCheckboxProps) => { ); }; - -export default DomainCheckbox; diff --git a/apps/builder/app/builder/features/topbar/domains.tsx b/apps/builder/app/builder/features/topbar/domains.tsx index b3db2809883a..a84f62f49724 100644 --- a/apps/builder/app/builder/features/topbar/domains.tsx +++ b/apps/builder/app/builder/features/topbar/domains.tsx @@ -21,6 +21,7 @@ import { } from "@webstudio-is/icons"; import { CollapsibleDomainSection } from "./collapsible-domain-section"; import { + Fragment, startTransition, useEffect, useOptimistic, @@ -34,12 +35,13 @@ import { useStore } from "@nanostores/react"; import { $publisherHost } from "~/shared/nano-states"; import { extractCname } from "./cname"; import { useEffectEvent } from "~/shared/hook-utils/effect-event"; -import DomainCheckbox from "./domain-checkbox"; +import { DomainCheckbox } from "./domain-checkbox"; import { CopyToClipboard } from "~/builder/shared/copy-to-clipboard"; import { RelativeTime } from "~/builder/shared/relative-time"; export type Domain = Project["domainsVirtual"][number]; -type DomainStatus = Project["domainsVirtual"][number]["status"]; + +type DomainStatus = Domain["status"]; const InputEllipsis = styled(InputField, { "&>input": { @@ -164,23 +166,28 @@ const StatusIcon = (props: { projectDomain: Domain; isLoading: boolean }) => { ); }; -const DomainItem = (props: { +const DomainItem = ({ + initiallyOpen, + projectDomain, + project, + refresh, +}: { initiallyOpen: boolean; projectDomain: Domain; - refresh: () => Promise; project: Project; + refresh: () => Promise; }) => { const timeSinceLastUpdateMs = - Date.now() - new Date(props.projectDomain.updatedAt).getTime(); + Date.now() - new Date(projectDomain.updatedAt).getTime(); const DAY_IN_MS = 24 * 60 * 60 * 1000; - const status = props.projectDomain.verified - ? (`VERIFIED_${props.projectDomain.status}` as `VERIFIED_${DomainStatus}`) + const status = projectDomain.verified + ? (`VERIFIED_${projectDomain.status}` as `VERIFIED_${DomainStatus}`) : `UNVERIFIED`; const [isStatusLoading, setIsStatusLoading] = useState( - props.initiallyOpen || + initiallyOpen || status === "VERIFIED_ACTIVE" || timeSinceLastUpdateMs > DAY_IN_MS ? false @@ -195,8 +202,8 @@ const DomainItem = (props: { const handleRemoveDomain = async () => { setIsRemoveInProgress(true); const result = await nativeClient.domain.remove.mutate({ - projectId: props.projectDomain.projectId, - domainId: props.projectDomain.domainId, + projectId: projectDomain.projectId, + domainId: projectDomain.domainId, }); if (result.success === false) { @@ -204,7 +211,7 @@ const DomainItem = (props: { return; } - await props.refresh(); + await refresh(); }; const [verifyError, setVerifyError] = useState(undefined); @@ -214,8 +221,8 @@ const DomainItem = (props: { setIsCheckStateInProgress(true); const verifyResult = await nativeClient.domain.verify.mutate({ - projectId: props.projectDomain.projectId, - domainId: props.projectDomain.domainId, + projectId: projectDomain.projectId, + domainId: projectDomain.domainId, }); if (verifyResult.success === false) { @@ -223,7 +230,7 @@ const DomainItem = (props: { return; } - await props.refresh(); + await refresh(); }); const [updateStatusError, setUpdateStatusError] = useState< @@ -235,8 +242,8 @@ const DomainItem = (props: { setIsCheckStateInProgress(true); const updateStatusResult = await nativeClient.domain.updateStatus.mutate({ - projectId: props.projectDomain.projectId, - domain: props.projectDomain.domain, + projectId: projectDomain.projectId, + domain: projectDomain.domain, }); setIsStatusLoading(false); @@ -246,7 +253,7 @@ const DomainItem = (props: { return; } - await props.refresh(); + await refresh(); }); const onceRef = useRef(false); @@ -272,67 +279,60 @@ const DomainItem = (props: { }); }, [status, handleVerify, handleUpdateStatus, isStatusLoading]); - const publisherHost = useStore($publisherHost); - const cnameEntryName = extractCname(props.projectDomain.domain); - const cnameEntryValue = `${props.projectDomain.cname}.customers.${publisherHost}`; - - const txtEntryName = - cnameEntryName === "@" - ? "_webstudio_is" - : `_webstudio_is.${cnameEntryName}`; - - const domainStatus = getStatus(props.projectDomain); - - const cnameRecord = { - type: "CNAME", - host: cnameEntryName, - value: cnameEntryValue, - ttl: 300, - } as const; - - const txtRecord = { - type: "TXT", - host: txtEntryName, - value: props.projectDomain.expectedTxtRecord, - ttl: 300, - } as const; - - const dnsRecords = [cnameRecord, txtRecord]; + const domainStatus = getStatus(projectDomain); const { isVerifiedActive, text } = getStatusText({ - projectDomain: props.projectDomain, + projectDomain, isLoading: false, }); + const publisherHost = useStore($publisherHost); + const cname = extractCname(projectDomain.domain); + const dnsRecords = [ + { + type: "CNAME", + host: cname, + value: `${projectDomain.cname}.customers.${publisherHost}`, + ttl: 300, + } as const, + { + type: "TXT", + host: cname === "@" ? "_webstudio_is" : `_webstudio_is.${cname}`, + value: projectDomain.expectedTxtRecord, + ttl: 300, + } as const, + ]; + return ( } - initiallyOpen={props.initiallyOpen} - title={props.projectDomain.domain} + initiallyOpen={initiallyOpen} + title={projectDomain.domain} suffix={ - + { - const url = new URL(`https://${props.projectDomain.domain}`); + const url = new URL(`https://${projectDomain.domain}`); window.open(url.href, "_blank"); event.preventDefault(); }} @@ -423,67 +423,45 @@ const DomainItem = (props: { - + TYPE - + NAME - + VALUE - - - - - - - } - /> - - - - - - } - /> - - - - - - - - } - /> - - - - - - } - /> + {dnsRecords.map((record, index) => ( + + + + + + + + } + /> + + + + + + } + /> + + ))} { // Sometimes Entri modal dialog hangs even if it's successful, // until they fix that, we'll just refresh the status here on every onClose event diff --git a/apps/builder/app/builder/features/topbar/entri.tsx b/apps/builder/app/builder/features/topbar/entri.tsx index cffbf3c01f5f..eacb1dcd6f7c 100644 --- a/apps/builder/app/builder/features/topbar/entri.tsx +++ b/apps/builder/app/builder/features/topbar/entri.tsx @@ -1,38 +1,118 @@ -import { Button, Text } from "@webstudio-is/design-system"; -import { - useEntri, - entriGlobalStyles, - type DnsRecord, - type EntriCloseDetail, -} from "~/shared/entri/entri"; - -export const Entri = ({ - dnsRecords, - domain, - onClose, -}: { - dnsRecords: DnsRecord[]; +import * as entri from "entrijs"; +import { useEffect, useState } from "react"; +import { useStore } from "@nanostores/react"; +import { globalCss, Button, Text, toast } from "@webstudio-is/design-system"; +import { trpcClient } from "~/shared/trpc/trpc-client"; +import { $userPlanFeatures } from "~/shared/nano-states"; +import { extractCname } from "./cname"; + +// https://developers.entri.com/docs/install +type DnsRecord = { + type: "CNAME" | "ALIAS" | "TXT"; + host: string; + value: string; + ttl: number; +}; + +type EntriCloseEvent = CustomEvent; + +declare global { + // https://developers.entri.com/docs/integrate-with-dns-providers + interface WindowEventMap { + onEntriClose: EntriCloseEvent; + } +} + +/** + * Our FloatingPanelPopover adds pointerEvents: "none" to the body. + * We open the entry dialog from the popover, so we need to allow pointer events on the entri dialog. + */ +const entriGlobalStyles = globalCss({ + body: { + "&>#entriApp": { + pointerEvents: "auto", + }, + }, +}); + +type EntriProps = { domain: string; - onClose: (detail: EntriCloseDetail) => void; -}) => { + dnsRecords: DnsRecord[]; + onClose: (detail: entri.EntriCloseEventDetail) => void; +}; + +const useEntri = ({ domain, dnsRecords, onClose }: EntriProps) => { + const [isOpen, setIsOpen] = useState(false); + + const { + load: entriTokenLoad, + data: entriTokenData, + error: entriTokenSystemError, + } = trpcClient.domain.getEntriToken.useQuery(); + + useEffect(() => { + const handleOnEntriClose = (event: EntriCloseEvent) => { + if (event.detail.domain === domain) { + onClose(event.detail); + setIsOpen(false); + } + }; + window.addEventListener("onEntriClose", handleOnEntriClose, false); + return () => { + window.removeEventListener("onEntriClose", handleOnEntriClose, false); + }; + }, [domain, onClose]); + + const showDialog = () => { + setIsOpen(true); + entriTokenLoad(undefined, async (data) => { + if (data.success) { + await entri.showEntri({ + applicationId: data.applicationId, + token: data.token, + dnsRecords, + prefilledDomain: domain, + // add redirect to www only when registered domain has www subdomain + wwwRedirect: extractCname(domain) === "www", + }); + } + }); + }; + + return { + isOpen, + showDialog, + error: + entriTokenSystemError ?? + (entriTokenData?.success === false ? entriTokenData.error : undefined), + }; +}; + +export const Entri = ({ domain, dnsRecords, onClose }: EntriProps) => { entriGlobalStyles(); + const { hasProPlan } = useStore($userPlanFeatures); const { error, isOpen, showDialog } = useEntri({ - onClose, domain, dnsRecords, + onClose, }); - return ( <> {error !== undefined && {error}} -