⚡ (customDomain) Add configuration modal for domain verification
Closes #742
This commit is contained in:
@ -635,3 +635,11 @@ export const ChevronLastIcon = (props: IconProps) => (
|
||||
<path d="M17 6v12" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const XCircleIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m15 9-6 6" />
|
||||
<path d="m9 9 6 6" />
|
||||
</Icon>
|
||||
)
|
||||
|
@ -10,7 +10,7 @@ export const deleteCustomDomain = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'DELETE',
|
||||
path: '/custom-domains',
|
||||
path: '/custom-domains/{name}',
|
||||
protect: true,
|
||||
summary: 'Delete custom domain',
|
||||
tags: ['Custom domains'],
|
||||
|
@ -2,9 +2,11 @@ import { router } from '@/helpers/server/trpc'
|
||||
import { createCustomDomain } from './createCustomDomain'
|
||||
import { deleteCustomDomain } from './deleteCustomDomain'
|
||||
import { listCustomDomains } from './listCustomDomains'
|
||||
import { verifyCustomDomain } from './verifyCustomDomain'
|
||||
|
||||
export const customDomainsRouter = router({
|
||||
createCustomDomain,
|
||||
deleteCustomDomain,
|
||||
listCustomDomains,
|
||||
verifyCustomDomain,
|
||||
})
|
||||
|
@ -0,0 +1,131 @@
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { DomainConfigResponse, DomainVerificationResponse } from '../types'
|
||||
import {
|
||||
DomainResponse,
|
||||
DomainVerificationStatus,
|
||||
domainResponseSchema,
|
||||
domainVerificationStatusSchema,
|
||||
} from '@typebot.io/schemas/features/customDomains'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const verifyCustomDomain = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/custom-domains/{name}/verify',
|
||||
protect: true,
|
||||
summary: 'Verify domain config',
|
||||
tags: ['Custom domains'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
status: domainVerificationStatusSchema,
|
||||
domainJson: domainResponseSchema,
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { workspaceId, name }, ctx: { user } }) => {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!workspace || isWriteWorkspaceForbidden(workspace, user))
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' })
|
||||
|
||||
let status: DomainVerificationStatus = 'Valid Configuration'
|
||||
|
||||
const [domainJson, configJson] = await Promise.all([
|
||||
getDomainResponse(name),
|
||||
getConfigResponse(name),
|
||||
])
|
||||
|
||||
if (domainJson?.error?.code === 'not_found') {
|
||||
status = 'Domain Not Found'
|
||||
} else if (domainJson.error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: domainJson.error.message,
|
||||
})
|
||||
} else if (!domainJson.verified) {
|
||||
status = 'Pending Verification'
|
||||
const verificationJson = await verifyDomain(name)
|
||||
|
||||
if (verificationJson && verificationJson.verified) {
|
||||
status = 'Valid Configuration'
|
||||
}
|
||||
} else if (configJson.misconfigured) {
|
||||
status = 'Invalid Configuration'
|
||||
} else {
|
||||
status = 'Valid Configuration'
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
domainJson,
|
||||
}
|
||||
})
|
||||
|
||||
const getDomainResponse = async (
|
||||
domain: string
|
||||
): Promise<DomainResponse & { error: { code: string; message: string } }> => {
|
||||
return await fetch(
|
||||
`https://api.vercel.com/v9/projects/${env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${domain}?teamId=${env.VERCEL_TEAM_ID}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.VERCEL_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
).then((res) => {
|
||||
return res.json()
|
||||
})
|
||||
}
|
||||
|
||||
const getConfigResponse = async (
|
||||
domain: string
|
||||
): Promise<DomainConfigResponse> => {
|
||||
return await fetch(
|
||||
`https://api.vercel.com/v6/domains/${domain}/config?teamId=${env.VERCEL_TEAM_ID}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.VERCEL_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
).then((res) => res.json())
|
||||
}
|
||||
|
||||
const verifyDomain = async (
|
||||
domain: string
|
||||
): Promise<DomainVerificationResponse> => {
|
||||
return await fetch(
|
||||
`https://api.vercel.com/v9/projects/${env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${domain}/verify?teamId=${env.VERCEL_TEAM_ID}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.VERCEL_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
).then((res) => res.json())
|
||||
}
|
@ -22,7 +22,7 @@ import { trpc } from '@/lib/trpc'
|
||||
const hostnameRegex =
|
||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
|
||||
|
||||
type CustomDomainModalProps = {
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@ -30,13 +30,13 @@ type CustomDomainModalProps = {
|
||||
onNewDomain: (customDomain: string) => void
|
||||
}
|
||||
|
||||
export const CustomDomainModal = ({
|
||||
export const CreateCustomDomainModal = ({
|
||||
workspaceId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNewDomain,
|
||||
domain = '',
|
||||
}: CustomDomainModalProps) => {
|
||||
}: Props) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(domain)
|
@ -0,0 +1,187 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
HStack,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Text,
|
||||
Box,
|
||||
Code,
|
||||
Stack,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react'
|
||||
import { XCircleIcon } from '@/components/icons'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
isOpen: boolean
|
||||
domain: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const CustomDomainConfigModal = ({
|
||||
workspaceId,
|
||||
isOpen,
|
||||
onClose,
|
||||
domain,
|
||||
}: Props) => {
|
||||
const { data, error } = trpc.customDomains.verifyCustomDomain.useQuery({
|
||||
name: domain,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const { domainJson, status } = data ?? {}
|
||||
|
||||
if (!status || status === 'Valid Configuration' || !domainJson) return null
|
||||
|
||||
if ('error' in domainJson) return null
|
||||
|
||||
const subdomain = getSubdomain(domainJson.name, domainJson.apexName)
|
||||
|
||||
const recordType = subdomain ? 'CNAME' : 'A'
|
||||
|
||||
const txtVerification =
|
||||
(status === 'Pending Verification' &&
|
||||
domainJson.verification?.find((x) => x.type === 'TXT')) ||
|
||||
null
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<XCircleIcon stroke="red.500" />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{status}
|
||||
</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{txtVerification ? (
|
||||
<Stack spacing="4">
|
||||
<Text>
|
||||
Please set the following <Code>TXT</Code> record on{' '}
|
||||
<Text as="span" fontWeight="bold">
|
||||
{domainJson.apexName}
|
||||
</Text>{' '}
|
||||
to prove ownership of{' '}
|
||||
<Text as="span" fontWeight="bold">
|
||||
{domainJson.name}
|
||||
</Text>
|
||||
:
|
||||
</Text>
|
||||
<HStack
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
spacing="6"
|
||||
>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">Type</Text>
|
||||
<Text fontSize="sm" fontFamily="mono">
|
||||
{txtVerification.type}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">Name</Text>
|
||||
<Text fontSize="sm" fontFamily="mono">
|
||||
{txtVerification.domain.slice(
|
||||
0,
|
||||
txtVerification.domain.length -
|
||||
domainJson.apexName.length -
|
||||
1
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">Value</Text>
|
||||
<Text fontSize="sm" fontFamily="mono">
|
||||
<Box text-overflow="ellipsis" white-space="nowrap">
|
||||
{txtVerification.value}
|
||||
</Box>
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
If you are using this domain for another site, setting this
|
||||
TXT record will transfer domain ownership away from that site
|
||||
and break it. Please exercise caution when setting this
|
||||
record.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
) : status === 'Unknown Error' ? (
|
||||
<Text mb="5" fontSize="sm">
|
||||
{error?.message}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack spacing={4}>
|
||||
<Text>
|
||||
To configure your{' '}
|
||||
{recordType === 'A' ? 'apex domain' : 'subdomain'} (
|
||||
<Box as="span" fontWeight="bold">
|
||||
{recordType === 'A' ? domainJson.apexName : domainJson.name}
|
||||
</Box>
|
||||
), set the following {recordType} record on your DNS provider to
|
||||
continue:
|
||||
</Text>
|
||||
<HStack justifyContent="space-between">
|
||||
<Stack>
|
||||
<Text fontWeight="bold">Type</Text>
|
||||
<Text fontFamily="mono" fontSize="sm">
|
||||
{recordType}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">Name</Text>
|
||||
<Text fontFamily="mono" fontSize="sm">
|
||||
{recordType === 'A' ? '@' : subdomain ?? 'www'}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">Value</Text>
|
||||
<Text fontFamily="mono" fontSize="sm">
|
||||
{recordType === 'A'
|
||||
? '76.76.21.21'
|
||||
: `cname.vercel-dns.com`}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">TTL</Text>
|
||||
<Text fontFamily="mono" fontSize="sm">
|
||||
86400
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
<Alert fontSize="sm">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
Note: for TTL, if <Code>86400</Code> is not available, set the
|
||||
highest value possible. Also, domain propagation can take up
|
||||
to an hour.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter as={HStack}>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const getSubdomain = (name: string, apexName: string) => {
|
||||
if (name === apexName) return null
|
||||
return name.slice(0, name.length - apexName.length - 1)
|
||||
}
|
@ -12,7 +12,7 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
|
||||
import React, { useState } from 'react'
|
||||
import { CustomDomainModal } from './CustomDomainModal'
|
||||
import { CreateCustomDomainModal } from './CreateCustomDomainModal'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
@ -83,7 +83,7 @@ export const CustomDomainsDropdown = ({
|
||||
return (
|
||||
<Menu isLazy placement="bottom-start" matchWidth>
|
||||
{workspace?.id && (
|
||||
<CustomDomainModal
|
||||
<CreateCustomDomainModal
|
||||
workspaceId={workspace.id}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
|
@ -0,0 +1,43 @@
|
||||
import { XCircleIcon } from '@/components/icons'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { Flex, Tooltip, useDisclosure } from '@chakra-ui/react'
|
||||
import { CustomDomainConfigModal } from './CustomDomainConfigModal'
|
||||
|
||||
type Props = {
|
||||
domain: string
|
||||
workspaceId: string
|
||||
}
|
||||
export default function DomainStatusIcon({ domain, workspaceId }: Props) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { showToast } = useToast()
|
||||
const { data, isLoading } = trpc.customDomains.verifyCustomDomain.useQuery(
|
||||
{
|
||||
name: domain,
|
||||
workspaceId,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
showToast({ description: err.message })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (isLoading || data?.status === 'Valid Configuration') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={data?.status}>
|
||||
<Flex onClick={onOpen} cursor="pointer">
|
||||
<XCircleIcon stroke="red.500" />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<CustomDomainConfigModal
|
||||
workspaceId={workspaceId}
|
||||
isOpen={isOpen}
|
||||
domain={domain}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
27
apps/builder/src/features/customDomains/types.ts
Normal file
27
apps/builder/src/features/customDomains/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// Copied from https://github.com/vercel/platforms/blob/main/lib/types.ts
|
||||
|
||||
// From https://vercel.com/docs/rest-api/endpoints#get-a-domain-s-configuration
|
||||
export interface DomainConfigResponse {
|
||||
configuredBy?: ('CNAME' | 'A' | 'http') | null
|
||||
acceptedChallenges?: ('dns-01' | 'http-01')[]
|
||||
misconfigured: boolean
|
||||
}
|
||||
|
||||
// From https://vercel.com/docs/rest-api/endpoints#verify-project-domain
|
||||
export interface DomainVerificationResponse {
|
||||
name: string
|
||||
apexName: string
|
||||
projectId: string
|
||||
redirect?: string | null
|
||||
redirectStatusCode?: (307 | 301 | 302 | 308) | null
|
||||
gitBranch?: string | null
|
||||
updatedAt?: number
|
||||
createdAt?: number
|
||||
verified: boolean
|
||||
verification?: {
|
||||
type: string
|
||||
domain: string
|
||||
value: string
|
||||
reason: string
|
||||
}[]
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { CloseIcon } from '@/components/icons'
|
||||
import { TrashIcon } from '@/components/icons'
|
||||
import { Seo } from '@/components/Seo'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
@ -26,6 +26,7 @@ import { TypebotHeader } from '@/features/editor/components/TypebotHeader'
|
||||
import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
|
||||
import { useI18n } from '@/locales'
|
||||
import { env } from '@typebot.io/env'
|
||||
import DomainStatusIcon from '@/features/customDomains/components/DomainStatusIcon'
|
||||
|
||||
export const SharePage = () => {
|
||||
const t = useI18n()
|
||||
@ -113,11 +114,17 @@ export const SharePage = () => {
|
||||
onPathnameChange={handlePathnameChange}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove custom URL"
|
||||
size="xs"
|
||||
onClick={() => handleCustomDomainChange(null)}
|
||||
/>
|
||||
{workspace?.id && (
|
||||
<DomainStatusIcon
|
||||
domain={typebot.customDomain.split('/')[0]}
|
||||
workspaceId={workspace.id}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
{isNotDefined(typebot?.customDomain) &&
|
||||
|
@ -1,6 +1,40 @@
|
||||
import { CustomDomain as CustomDomainInDb } from '@typebot.io/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const domainVerificationStatusSchema = z.enum([
|
||||
'Valid Configuration',
|
||||
'Invalid Configuration',
|
||||
'Domain Not Found',
|
||||
'Pending Verification',
|
||||
'Unknown Error',
|
||||
])
|
||||
export type DomainVerificationStatus = z.infer<
|
||||
typeof domainVerificationStatusSchema
|
||||
>
|
||||
|
||||
export const domainResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
apexName: z.string(),
|
||||
projectId: z.string(),
|
||||
redirect: z.string().nullable(),
|
||||
redirectStatusCode: z.number().nullable(),
|
||||
gitBranch: z.string().nullable(),
|
||||
updatedAt: z.number().nullable(),
|
||||
createdAt: z.number().nullable(),
|
||||
verified: z.boolean(),
|
||||
verification: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
domain: z.string(),
|
||||
value: z.string(),
|
||||
reason: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
export type DomainResponse = z.infer<typeof domainResponseSchema>
|
||||
|
||||
const domainNameRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/
|
||||
|
||||
export const customDomainSchema = z.object({
|
||||
|
Reference in New Issue
Block a user