2
0

(customDomain) Add configuration modal for domain verification

Closes #742
This commit is contained in:
Baptiste Arnaud
2023-09-18 17:16:30 +02:00
parent 21ad061f7b
commit 322c48cddc
11 changed files with 447 additions and 8 deletions

View File

@ -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>
)

View File

@ -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'],

View File

@ -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,
})

View File

@ -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())
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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}

View File

@ -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}
/>
</>
)
}

View 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
}[]
}

View File

@ -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) &&

View File

@ -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({