2
0

(customDomains) Fix custom domain update feedback

This commit is contained in:
Baptiste Arnaud
2023-08-21 15:32:27 +02:00
parent dc4c19a755
commit c08e0cdb0a
19 changed files with 506 additions and 104 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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"]')

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { TrashIcon } from '@/components/icons'
import { CloseIcon } from '@/components/icons'
import { Seo } from '@/components/Seo'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
@ -33,7 +33,7 @@ export const SharePage = () => {
const { showToast } = useToast()
const handlePublicIdChange = async (publicId: string) => {
updateTypebot({ updates: { publicId } })
updateTypebot({ updates: { publicId }, save: true })
}
const publicId = typebot
@ -50,7 +50,7 @@ export const SharePage = () => {
}
const handleCustomDomainChange = (customDomain: string | null) =>
updateTypebot({ updates: { customDomain } })
updateTypebot({ updates: { customDomain }, save: true })
const checkIfPathnameIsValid = (pathname: string) => {
const isCorrectlyFormatted =
@ -113,8 +113,8 @@ export const SharePage = () => {
onPathnameChange={handlePathnameChange}
/>
<IconButton
icon={<TrashIcon />}
aria-label="Remove custom domain"
icon={<CloseIcon />}
aria-label="Remove custom URL"
size="xs"
onClick={() => handleCustomDomainChange(null)}
/>

View File

@ -11,6 +11,7 @@ import {
} from '../helpers/sanitizers'
import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { Prisma } from '@typebot.io/prisma'
export const updateTypebot = authenticatedProcedure
.meta({
@ -134,9 +135,13 @@ export const updateTypebot = authenticatedProcedure
folderId: typebot.folderId,
variables: typebot.variables,
edges: typebot.edges,
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
publicId: typebot.publicId ?? undefined,
customDomain: typebot.customDomain ?? undefined,
resultsTablePreferences:
typebot.resultsTablePreferences === null
? Prisma.DbNull
: typebot.resultsTablePreferences,
publicId: typebot.publicId === null ? null : typebot.publicId,
customDomain:
typebot.customDomain === null ? null : typebot.customDomain,
isClosed: typebot.isClosed,
},
})

View File

@ -2,7 +2,7 @@ import { MemberInWorkspace, User } from '@typebot.io/prisma'
export const isWriteWorkspaceForbidden = (
workspace: {
members: MemberInWorkspace[]
members: Pick<MemberInWorkspace, 'userId' | 'role'>[]
},
user: Pick<User, 'email' | 'id'>
) => {

View File

@ -11,6 +11,7 @@ import { workspaceRouter } from '@/features/workspace/api/router'
import { router } from '../../trpc'
import { analyticsRouter } from '@/features/analytics/api/router'
import { collaboratorsRouter } from '@/features/collaboration/api/router'
import { customDomainsRouter } from '@/features/customDomains/api/router'
export const trpcRouter = router({
getAppVersionProcedure,
@ -25,6 +26,7 @@ export const trpcRouter = router({
credentials: credentialsRouter,
theme: themeRouter,
collaborators: collaboratorsRouter,
customDomains: customDomainsRouter,
})
export type AppRouter = typeof trpcRouter

View File

@ -10,6 +10,7 @@ import {
notAuthenticated,
} from '@typebot.io/lib/api'
// TODO: delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)

View File

@ -16375,9 +16375,6 @@
"additionalProperties": false
}
},
"isClosed": {
"type": "boolean"
},
"resultsTablePreferences": {
"type": "object",
"properties": {
@ -16415,6 +16412,9 @@
"customDomain": {
"type": "string",
"nullable": true
},
"isClosed": {
"type": "boolean"
}
},
"additionalProperties": false
@ -31137,6 +31137,205 @@
}
}
}
},
"/custom-domains": {
"post": {
"operationId": "customDomains-createCustomDomain",
"summary": "Create custom domain",
"tags": [
"Workspace",
"Custom domains"
],
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"workspaceId",
"name"
],
"additionalProperties": false
}
}
}
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"customDomain": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"name",
"createdAt"
],
"additionalProperties": false
}
},
"required": [
"customDomain"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
},
"delete": {
"operationId": "customDomains-deleteCustomDomain",
"summary": "Delete custom domain",
"tags": [
"Workspace",
"Custom domains"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "name",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"enum": [
"success"
]
}
},
"required": [
"message"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
},
"get": {
"operationId": "customDomains-listCustomDomains",
"summary": "List custom domains",
"tags": [
"Workspace",
"Custom domains"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"customDomains": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"name",
"createdAt"
],
"additionalProperties": false
}
}
},
"required": [
"customDomains"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
}
},
"components": {

View File

@ -1,7 +1,7 @@
{
"name": "@typebot.io/react",
"version": "0.1.17",
"description": "Convenient library to display typebots on your Next.js website",
"description": "Convenient library to display typebots on your React app",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",

View File

@ -0,0 +1,10 @@
import { CustomDomain as CustomDomainInDb } from '@typebot.io/prisma'
import { z } from 'zod'
const domainNameRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/
export const customDomainSchema = z.object({
name: z.string().refine((name) => domainNameRegex.test(name)),
workspaceId: z.string(),
createdAt: z.date(),
}) satisfies z.ZodType<CustomDomainInDb>

View File

@ -39,7 +39,7 @@ const resultsTablePreferencesSchema = z.object({
})
const isPathNameCompatible = (str: string) =>
/^([a-z0-9]+-[a-z0-9]*)*$/.test(str) || /^[a-z0-9]*$/.test(str)
/^([a-zA-Z0-9]+(-|.)[a-zA-z0-9]*)*$/.test(str) || /^[a-zA-Z0-9]*$/.test(str)
const isDomainNameWithPathNameCompatible = (str: string) =>
/^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[\w-\/]*)?)$/.test(