⚡ (customDomains) Fix custom domain update feedback
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { customDomainSchema } from '@typebot.io/schemas/features/customDomains'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
|
||||
import got, { HTTPError } from 'got'
|
||||
|
||||
export const createCustomDomain = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/custom-domains',
|
||||
protect: true,
|
||||
summary: 'Create custom domain',
|
||||
tags: ['Custom domains'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
customDomain: customDomainSchema.pick({
|
||||
name: true,
|
||||
createdAt: true,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.mutation(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' })
|
||||
|
||||
const existingCustomDomain = await prisma.customDomain.findFirst({
|
||||
where: { name },
|
||||
})
|
||||
|
||||
if (existingCustomDomain)
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Custom domain already registered',
|
||||
})
|
||||
|
||||
try {
|
||||
await createDomainOnVercel(name)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
if (err instanceof HTTPError && err.response.statusCode !== 409)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to create custom domain on Vercel',
|
||||
})
|
||||
}
|
||||
|
||||
const customDomain = await prisma.customDomain.create({
|
||||
data: {
|
||||
name,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
return { customDomain }
|
||||
})
|
||||
|
||||
const createDomainOnVercel = (name: string) =>
|
||||
got.post({
|
||||
url: `https://api.vercel.com/v10/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains?teamId=${process.env.VERCEL_TEAM_ID}`,
|
||||
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
||||
json: { name },
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
|
||||
import got from 'got'
|
||||
|
||||
export const deleteCustomDomain = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'DELETE',
|
||||
path: '/custom-domains',
|
||||
protect: true,
|
||||
summary: 'Delete custom domain',
|
||||
tags: ['Custom domains'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
message: z.literal('success'),
|
||||
})
|
||||
)
|
||||
.mutation(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' })
|
||||
|
||||
try {
|
||||
await deleteDomainOnVercel(name)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to delete domain on Vercel',
|
||||
})
|
||||
}
|
||||
await prisma.customDomain.deleteMany({
|
||||
where: {
|
||||
name,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
return { message: 'success' }
|
||||
})
|
||||
|
||||
const deleteDomainOnVercel = (name: string) =>
|
||||
got.delete({
|
||||
url: `https://api.vercel.com/v9/projects/${process.env.NEXT_PUBLIC_VERCEL_VIEWER_PROJECT_NAME}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`,
|
||||
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
|
||||
import { customDomainSchema } from '@typebot.io/schemas/features/customDomains'
|
||||
|
||||
export const listCustomDomains = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/custom-domains',
|
||||
protect: true,
|
||||
summary: 'List custom domains',
|
||||
tags: ['Custom domains'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
customDomains: z.array(
|
||||
customDomainSchema.pick({
|
||||
name: true,
|
||||
createdAt: true,
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
customDomains: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!workspace || isReadWorkspaceFobidden(workspace, user))
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'No workspaces found' })
|
||||
|
||||
const descSortedCustomDomains = workspace.customDomains.sort(
|
||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||
)
|
||||
|
||||
return { customDomains: descSortedCustomDomains }
|
||||
})
|
||||
10
apps/builder/src/features/customDomains/api/router.ts
Normal file
10
apps/builder/src/features/customDomains/api/router.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { createCustomDomain } from './createCustomDomain'
|
||||
import { deleteCustomDomain } from './deleteCustomDomain'
|
||||
import { listCustomDomains } from './listCustomDomains'
|
||||
|
||||
export const customDomainsRouter = router({
|
||||
createCustomDomain,
|
||||
deleteCustomDomain,
|
||||
listCustomDomains,
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createCustomDomainQuery } from '../queries/createCustomDomainQuery'
|
||||
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])$/
|
||||
@@ -46,6 +46,24 @@ export const CustomDomainModal = ({
|
||||
})
|
||||
|
||||
const { showToast } = useToast()
|
||||
const { mutate } = trpc.customDomains.createCustomDomain.useMutation({
|
||||
onMutate: () => {
|
||||
setIsLoading(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
title: 'Error while creating custom domain',
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
onNewDomain(data.customDomain.name)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue === '' || !isOpen) return
|
||||
@@ -62,15 +80,7 @@ export const CustomDomainModal = ({
|
||||
|
||||
const onAddDomainClick = async () => {
|
||||
if (!hostnameRegex.test(inputValue)) return
|
||||
setIsLoading(true)
|
||||
const { error } = await createCustomDomainQuery(workspaceId, {
|
||||
name: inputValue,
|
||||
})
|
||||
setIsLoading(false)
|
||||
if (error)
|
||||
return showToast({ title: error.name, description: error.message })
|
||||
onNewDomain(inputValue)
|
||||
onClose()
|
||||
mutate({ name: inputValue, workspaceId })
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -15,8 +15,7 @@ import React, { useState } from 'react'
|
||||
import { CustomDomainModal } from './CustomDomainModal'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { useCustomDomains } from '../hooks/useCustomDomains'
|
||||
import { deleteCustomDomainQuery } from '../queries/deleteCustomDomainQuery'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
currentCustomDomain?: string
|
||||
@@ -32,10 +31,36 @@ export const CustomDomainsDropdown = ({
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { workspace } = useWorkspace()
|
||||
const { showToast } = useToast()
|
||||
const { customDomains, mutate } = useCustomDomains({
|
||||
workspaceId: workspace?.id,
|
||||
onError: (error) =>
|
||||
showToast({ title: error.name, description: error.message }),
|
||||
const { data, refetch } = trpc.customDomains.listCustomDomains.useQuery(
|
||||
{
|
||||
workspaceId: workspace?.id as string,
|
||||
},
|
||||
{
|
||||
enabled: !!workspace?.id,
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
title: 'Error while fetching custom domains',
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
const { mutate } = trpc.customDomains.deleteCustomDomain.useMutation({
|
||||
onMutate: ({ name }) => {
|
||||
setIsDeleting(name)
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
title: 'Error while deleting custom domain',
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsDeleting('')
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const handleMenuItemClick = (customDomain: string) => () =>
|
||||
@@ -45,27 +70,14 @@ export const CustomDomainsDropdown = ({
|
||||
(domainName: string) => async (e: React.MouseEvent) => {
|
||||
if (!workspace) return
|
||||
e.stopPropagation()
|
||||
setIsDeleting(domainName)
|
||||
const { error } = await deleteCustomDomainQuery(workspace.id, domainName)
|
||||
setIsDeleting('')
|
||||
if (error)
|
||||
return showToast({ title: error.name, description: error.message })
|
||||
mutate({
|
||||
customDomains: (customDomains ?? []).filter(
|
||||
(cd) => cd.name !== domainName
|
||||
),
|
||||
name: domainName,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
}
|
||||
|
||||
const handleNewDomain = (domain: string) => {
|
||||
if (!workspace) return
|
||||
mutate({
|
||||
customDomains: [
|
||||
...(customDomains ?? []),
|
||||
{ name: domain, workspaceId: workspace?.id },
|
||||
],
|
||||
})
|
||||
handleMenuItemClick(domain)()
|
||||
const handleNewDomain = (name: string) => {
|
||||
onCustomDomainSelect(name)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,7 +104,7 @@ export const CustomDomainsDropdown = ({
|
||||
</MenuButton>
|
||||
<MenuList maxW="500px" shadow="lg">
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
{(customDomains ?? []).map((customDomain) => (
|
||||
{(data?.customDomains ?? []).map((customDomain) => (
|
||||
<Button
|
||||
role="menuitem"
|
||||
minH="40px"
|
||||
@@ -107,6 +119,7 @@ export const CustomDomainsDropdown = ({
|
||||
>
|
||||
{customDomain.name}
|
||||
<IconButton
|
||||
as="span"
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove domain"
|
||||
size="xs"
|
||||
|
||||
@@ -34,7 +34,7 @@ test('should be able to connect custom domain', async ({ page }) => {
|
||||
'Enter'
|
||||
)
|
||||
await expect(page.locator('text="custom-path"')).toBeVisible()
|
||||
await page.click('[aria-label="Remove custom domain"]')
|
||||
await page.click('[aria-label="Remove custom URL"]')
|
||||
await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden()
|
||||
await page.click('button >> text=Add my domain')
|
||||
await page.click('[aria-label="Remove domain"]')
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CustomDomain } from '@typebot.io/prisma'
|
||||
import { stringify } from 'qs'
|
||||
import { fetcher } from '@/helpers/fetcher'
|
||||
import useSWR from 'swr'
|
||||
|
||||
export const useCustomDomains = ({
|
||||
workspaceId,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ customDomains: Omit<CustomDomain, 'createdAt'>[] },
|
||||
Error
|
||||
>(
|
||||
workspaceId ? `/api/customDomains?${stringify({ workspaceId })}` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
customDomains: data?.customDomains,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { CustomDomain, Credentials } from '@typebot.io/prisma'
|
||||
import { stringify } from 'qs'
|
||||
import { sendRequest } from '@typebot.io/lib'
|
||||
|
||||
export const createCustomDomainQuery = async (
|
||||
workspaceId: string,
|
||||
customDomain: Omit<CustomDomain, 'createdAt' | 'workspaceId'>
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/customDomains?${stringify({ workspaceId })}`,
|
||||
method: 'POST',
|
||||
body: customDomain,
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Credentials } from '@typebot.io/prisma'
|
||||
import { stringify } from 'qs'
|
||||
import { sendRequest } from '@typebot.io/lib'
|
||||
|
||||
export const deleteCustomDomainQuery = async (
|
||||
workspaceId: string,
|
||||
customDomain: string
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/customDomains/${customDomain}?${stringify({ workspaceId })}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
Reference in New Issue
Block a user