diff --git a/apps/builder/assets/emails/invitationToCollaborate.mjml b/apps/builder/assets/emails/invitationToCollaborate.mjml
new file mode 100644
index 000000000..338df2b86
--- /dev/null
+++ b/apps/builder/assets/emails/invitationToCollaborate.mjml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ You have been invited to collaborate on a typebot created by ${email}
+ From now on you will see this typebot in your dashboard under the "Shared with me "button 👍
+
+
+
+
+ See the typebot
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/builder/assets/emails/invitationToCollaborate.ts b/apps/builder/assets/emails/invitationToCollaborate.ts
new file mode 100644
index 000000000..1c668f046
--- /dev/null
+++ b/apps/builder/assets/emails/invitationToCollaborate.ts
@@ -0,0 +1,506 @@
+export const invitationToCollaborate = (
+ email: string,
+ typebotUrl: string
+) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You have been invited to
+ collaborate on a typebot created
+ by ${email}
+
+
+
+
+
+
+ From now on you will see this
+ typebot in your dashboard under
+ the "Shared with me" button 👍
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/apps/builder/assets/emails/typebot2-paid-user-anouncement.mjml b/apps/builder/assets/emails/typebot2-paid-user-anouncement.mjml
new file mode 100644
index 000000000..275440b68
--- /dev/null
+++ b/apps/builder/assets/emails/typebot2-paid-user-anouncement.mjml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+ .footer-link {
+ color: #A0AEC0
+ }
+
+
+
+
+
+
+
+
+
+
+
+ You receive this email because you are the owner of a {{PLAN}} plan on Typebot. Which means you can keep all your perks on Typebot 2.0 as well 😍
+ All you have to do is to Create your new account by clicking on the button below.
+
+
+
+
+ Create my account
+
+
+
+
+ Your plan should automatically be applied. If you have any issue, hit reply 😃
+ I'm eagerly waiting for your feedback! Let's make this tool amazing.
+ Baptiste,
+ Founder of Typebot.
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/builder/assets/icons.tsx b/apps/builder/assets/icons.tsx
index 27189ddda..e368bc287 100644
--- a/apps/builder/assets/icons.tsx
+++ b/apps/builder/assets/icons.tsx
@@ -353,3 +353,12 @@ export const GithubIcon = (props: IconProps) => (
)
+
+export const UsersIcon = (props: IconProps) => (
+
+
+
+
+
+
+)
diff --git a/apps/builder/components/account/SubscriptionTag.tsx b/apps/builder/components/account/SubscriptionTag.tsx
index 012dc6289..58b7d9588 100644
--- a/apps/builder/components/account/SubscriptionTag.tsx
+++ b/apps/builder/components/account/SubscriptionTag.tsx
@@ -10,7 +10,7 @@ export const SubscriptionTag = ({ plan }: { plan?: Plan }) => {
return Lifetime plan
}
case Plan.OFFERED: {
- return Offered
+ return Offered
}
case Plan.PRO: {
return Pro plan
diff --git a/apps/builder/components/dashboard/FolderContent.tsx b/apps/builder/components/dashboard/FolderContent.tsx
index fd48bcac7..30cbcbd86 100644
--- a/apps/builder/components/dashboard/FolderContent.tsx
+++ b/apps/builder/components/dashboard/FolderContent.tsx
@@ -12,6 +12,7 @@ import {
Wrap,
} from '@chakra-ui/react'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
+import { useUser } from 'contexts/UserContext'
import React, { useEffect, useState } from 'react'
import { createFolder, useFolders } from 'services/folders'
import {
@@ -19,11 +20,13 @@ import {
TypebotInDashboard,
useTypebots,
} from 'services/typebots'
+import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
import { AnnoucementModal } from './annoucements/AnnoucementModal'
import { BackButton } from './FolderContent/BackButton'
import { CreateBotButton } from './FolderContent/CreateBotButton'
import { CreateFolderButton } from './FolderContent/CreateFolderButton'
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
+import { SharedTypebotsButton } from './FolderContent/SharedTypebotsButton'
import { TypebotButton } from './FolderContent/TypebotButton'
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
@@ -32,6 +35,7 @@ type Props = { folder: DashboardFolder | null }
const dragDistanceTolerance = 20
export const FolderContent = ({ folder }: Props) => {
+ const { user } = useUser()
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const {
setDraggedTypebot,
@@ -75,6 +79,17 @@ export const FolderContent = ({ folder }: Props) => {
},
})
+ const { totalSharedTypebots, isLoading: isSharedTypebotsCountLoading } =
+ useSharedTypebotsCount({
+ userId: folder === null ? user?.id : undefined,
+ onError: (error) => {
+ toast({
+ title: "Couldn't fetch shared typebots",
+ description: error.message,
+ })
+ },
+ })
+
useEffect(() => {
if (
typebots &&
@@ -182,6 +197,8 @@ export const FolderContent = ({ folder }: Props) => {
folderId={folder?.id}
isLoading={isTypebotLoading}
/>
+ {isSharedTypebotsCountLoading && }
+ {totalSharedTypebots > 0 && }
{isFolderLoading && }
{folders &&
folders.map((folder) => (
diff --git a/apps/builder/components/dashboard/FolderContent/SharedTypebotsButton.tsx b/apps/builder/components/dashboard/FolderContent/SharedTypebotsButton.tsx
new file mode 100644
index 000000000..d794008ed
--- /dev/null
+++ b/apps/builder/components/dashboard/FolderContent/SharedTypebotsButton.tsx
@@ -0,0 +1,40 @@
+import React from 'react'
+import { Button, Flex, Text, VStack, WrapItem } from '@chakra-ui/react'
+import { useRouter } from 'next/router'
+import { UsersIcon } from 'assets/icons'
+
+export const SharedTypebotsButton = () => {
+ const router = useRouter()
+
+ const handleTypebotClick = () => router.push(`/typebots/shared`)
+
+ return (
+
+
+
+
+
+ Shared with me
+
+
+ )
+}
diff --git a/apps/builder/components/dashboard/FolderContent/TypebotButton.tsx b/apps/builder/components/dashboard/FolderContent/TypebotButton.tsx
index 2bbfb1713..66a7c7341 100644
--- a/apps/builder/components/dashboard/FolderContent/TypebotButton.tsx
+++ b/apps/builder/components/dashboard/FolderContent/TypebotButton.tsx
@@ -22,13 +22,15 @@ import { useDebounce } from 'use-debounce'
type ChatbotCardProps = {
typebot: Pick
- onTypebotDeleted: () => void
- onMouseDown: (e: React.MouseEvent) => void
+ isReadOnly?: boolean
+ onTypebotDeleted?: () => void
+ onMouseDown?: (e: React.MouseEvent) => void
}
export const TypebotButton = ({
typebot,
onTypebotDeleted,
+ isReadOnly = false,
onMouseDown,
}: ChatbotCardProps) => {
const router = useRouter()
@@ -55,13 +57,14 @@ export const TypebotButton = ({
}
const handleDeleteTypebotClick = async () => {
+ if (isReadOnly) return
const { error } = await deleteTypebot(typebot.id)
if (error)
return toast({
title: "Couldn't delete typebot",
description: error.message,
})
- onTypebotDeleted()
+ if (onTypebotDeleted) onTypebotDeleted()
}
const handleDuplicateClick = async (e: React.MouseEvent) => {
@@ -98,28 +101,32 @@ export const TypebotButton = ({
onMouseDown={onMouseDown}
cursor="pointer"
>
- }
- pos="absolute"
- top="20px"
- left="20px"
- aria-label="Drag"
- cursor="grab"
- variant="ghost"
- colorScheme="blue"
- size="sm"
- />
-
- Duplicate
-
- Delete
-
-
+ {!isReadOnly && (
+ <>
+ }
+ pos="absolute"
+ top="20px"
+ left="20px"
+ aria-label="Drag"
+ cursor="grab"
+ variant="ghost"
+ colorScheme="blue"
+ size="sm"
+ />
+
+ Duplicate
+
+ Delete
+
+
+ >
+ )}
{typebot.name}
-
- Are you sure you want to delete your Typebot "{typebot.name}
- ".
-
- All associated data will be lost.
-
- }
- confirmButtonLabel="Delete"
- onConfirm={handleDeleteTypebotClick}
- isOpen={isDeleteOpen}
- onClose={onDeleteClose}
- />
+ {!isReadOnly && (
+
+ Are you sure you want to delete your Typebot "{typebot.name}
+ ".
+
+ All associated data will be lost.
+
+ }
+ confirmButtonLabel="Delete"
+ onConfirm={handleDeleteTypebotClick}
+ isOpen={isDeleteOpen}
+ onClose={onDeleteClose}
+ />
+ )}
)
}
diff --git a/apps/builder/components/share/customDomain/CustomDomainModal.tsx b/apps/builder/components/share/customDomain/CustomDomainModal.tsx
index b794bab6e..f02f8cfb3 100644
--- a/apps/builder/components/share/customDomain/CustomDomainModal.tsx
+++ b/apps/builder/components/share/customDomain/CustomDomainModal.tsx
@@ -17,7 +17,7 @@ import {
Tooltip,
} from '@chakra-ui/react'
import { useEffect, useRef, useState } from 'react'
-import { createCustomDomain } from 'services/customDomains'
+import { createCustomDomain } from 'services/user'
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])$/
diff --git a/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx
index da6c34159..62f2974b9 100644
--- a/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx
+++ b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx
@@ -15,7 +15,7 @@ import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
import React, { useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { CustomDomainModal } from './CustomDomainModal'
-import { deleteCustomDomain, useCustomDomains } from 'services/customDomains'
+import { deleteCustomDomain, useCustomDomains } from 'services/user'
type Props = Omit & {
currentCustomDomain?: string
diff --git a/apps/builder/components/shared/CredentialsDropdown.tsx b/apps/builder/components/shared/CredentialsDropdown.tsx
index 5064f58ef..d28283ca0 100644
--- a/apps/builder/components/shared/CredentialsDropdown.tsx
+++ b/apps/builder/components/shared/CredentialsDropdown.tsx
@@ -15,7 +15,7 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { useRouter } from 'next/router'
import { CredentialsType } from 'models'
-import { deleteCredentials, useCredentials } from 'services/credentials'
+import { deleteCredentials, useCredentials } from 'services/user'
type Props = Omit & {
type: CredentialsType
diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SmtpConfigModal.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SmtpConfigModal.tsx
index 55037189d..5c5c55d70 100644
--- a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SmtpConfigModal.tsx
+++ b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SmtpConfigModal.tsx
@@ -12,7 +12,7 @@ import {
import { useUser } from 'contexts/UserContext'
import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react'
-import { createCredentials } from 'services/credentials'
+import { createCredentials } from 'services/user'
import { testSmtpConfig } from 'services/integrations'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'
diff --git a/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx
new file mode 100644
index 000000000..360706a48
--- /dev/null
+++ b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx
@@ -0,0 +1,248 @@
+import {
+ Stack,
+ HStack,
+ Input,
+ Button,
+ useToast,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
+ SkeletonCircle,
+ SkeletonText,
+} from '@chakra-ui/react'
+import { ChevronLeftIcon } from 'assets/icons'
+import { useTypebot } from 'contexts/TypebotContext'
+import { useUser } from 'contexts/UserContext'
+import { CollaborationType } from 'db'
+import React, { FormEvent, useState } from 'react'
+import {
+ deleteCollaborator,
+ updateCollaborator,
+ useCollaborators,
+} from 'services/typebots/collaborators'
+import {
+ useInvitations,
+ updateInvitation,
+ deleteInvitation,
+ sendInvitation,
+} from 'services/typebots/invitations'
+import {
+ CollaboratorIdentityContent,
+ CollaboratorItem,
+} from './CollaboratorButton'
+
+export const CollaborationList = () => {
+ const { user } = useUser()
+ const { typebot, owner } = useTypebot()
+ const [invitationType, setInvitationType] = useState(
+ CollaborationType.READ
+ )
+ const [invitationEmail, setInvitationEmail] = useState('')
+ const [isSendingInvitation, setIsSendingInvitation] = useState(false)
+
+ console.log(user, owner)
+ const isOwner = user?.email === owner?.email
+
+ const toast = useToast({
+ position: 'top-right',
+ status: 'error',
+ })
+ const {
+ collaborators,
+ isLoading: isCollaboratorsLoading,
+ mutate: mutateCollaborators,
+ } = useCollaborators({
+ typebotId: typebot?.id,
+ onError: (e) =>
+ toast({
+ title: "Couldn't fetch collaborators",
+ description: e.message,
+ }),
+ })
+ const {
+ invitations,
+ isLoading: isInvitationsLoading,
+ mutate: mutateInvitations,
+ } = useInvitations({
+ typebotId: typebot?.id,
+ onError: (e) =>
+ toast({ title: "Couldn't fetch collaborators", description: e.message }),
+ })
+
+ const handleChangeInvitationCollabType =
+ (email: string) => async (type: CollaborationType) => {
+ if (!typebot || !isOwner) return
+ const { error } = await updateInvitation(typebot?.id, email, { type })
+ if (error) return toast({ title: error.name, description: error.message })
+ mutateInvitations({
+ invitations: (invitations ?? []).map((i) =>
+ i.email === email ? { ...i, type } : i
+ ),
+ })
+ }
+ const handleDeleteInvitation = (email: string) => async () => {
+ if (!typebot || !isOwner) return
+ const { error } = await deleteInvitation(typebot?.id, email)
+ if (error) return toast({ title: error.name, description: error.message })
+ mutateInvitations({
+ invitations: (invitations ?? []).filter((i) => i.email !== email),
+ })
+ }
+
+ const handleChangeCollaborationType =
+ (userId: string) => async (type: CollaborationType) => {
+ if (!typebot || !isOwner) return
+ const { error } = await updateCollaborator(typebot?.id, userId, { type })
+ if (error) return toast({ title: error.name, description: error.message })
+ mutateCollaborators({
+ collaborators: (collaborators ?? []).map((c) =>
+ c.userId === userId ? { ...c, type } : c
+ ),
+ })
+ }
+ const handleDeleteCollaboration = (userId: string) => async () => {
+ if (!typebot || !isOwner) return
+ const { error } = await deleteCollaborator(typebot?.id, userId)
+ if (error) return toast({ title: error.name, description: error.message })
+ mutateCollaborators({
+ collaborators: (collaborators ?? []).filter((c) => c.userId !== userId),
+ })
+ }
+
+ const handleInvitationSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!typebot || !isOwner) return
+ setIsSendingInvitation(true)
+ const { error } = await sendInvitation(typebot.id, {
+ email: invitationEmail,
+ type: invitationType,
+ })
+ setIsSendingInvitation(false)
+ mutateInvitations({ invitations: invitations ?? [] })
+ mutateCollaborators({ collaborators: collaborators ?? [] })
+ if (error) return toast({ title: error.name, description: error.message })
+ toast({ status: 'success', title: 'Invitation sent! 📧' })
+ setInvitationEmail('')
+ }
+
+ const hasNobody =
+ (collaborators ?? []).length > 0 ||
+ ((invitations ?? []).length > 0 &&
+ !isInvitationsLoading &&
+ !isCollaboratorsLoading)
+
+ return (
+
+ {isOwner && (
+
+ setInvitationEmail(e.target.value)}
+ rounded="md"
+ />
+
+
+
+ Invite
+
+
+ )}
+ {owner && (collaborators ?? []).length > 0 && (
+
+ )}
+ {invitations?.map(({ email, type }) => (
+
+ ))}
+ {collaborators?.map(({ user, type, userId }) => (
+
+ ))}
+ {(isCollaboratorsLoading || isInvitationsLoading) && (
+
+
+
+
+ )}
+
+ )
+}
+
+const CollaborationTypeMenuButton = ({
+ type,
+ onChange,
+}: {
+ type: CollaborationType
+ onChange: (type: CollaborationType) => void
+}) => {
+ return (
+
+ }
+ >
+ {convertCollaborationTypeEnumToReadable(type)}
+
+
+ onChange(CollaborationType.READ)}>
+ {convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
+
+ onChange(CollaborationType.WRITE)}>
+ {convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
+
+
+
+ )
+}
+
+export const convertCollaborationTypeEnumToReadable = (
+ type: CollaborationType
+) => {
+ switch (type) {
+ case CollaborationType.READ:
+ return 'Can view'
+ case CollaborationType.WRITE:
+ return 'Can edit'
+ }
+}
diff --git a/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationMenuButton.tsx b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationMenuButton.tsx
new file mode 100644
index 000000000..6ba377752
--- /dev/null
+++ b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationMenuButton.tsx
@@ -0,0 +1,30 @@
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ IconButton,
+ Tooltip,
+} from '@chakra-ui/react'
+import { UsersIcon } from 'assets/icons'
+import React from 'react'
+import { CollaborationList } from './CollaborationList'
+
+export const CollaborationMenuButton = () => {
+ return (
+
+
+
+
+ }
+ aria-label="Show collaboration menu"
+ />
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaboratorButton.tsx b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaboratorButton.tsx
new file mode 100644
index 000000000..f82fadc6d
--- /dev/null
+++ b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaboratorButton.tsx
@@ -0,0 +1,101 @@
+import {
+ Avatar,
+ HStack,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
+ Stack,
+ Tag,
+ Text,
+} from '@chakra-ui/react'
+import { CollaborationType } from 'db'
+import React from 'react'
+import { convertCollaborationTypeEnumToReadable } from './CollaborationList'
+
+type Props = {
+ image?: string
+ name?: string
+ email: string
+ type: CollaborationType
+ isGuest?: boolean
+ isOwner: boolean
+ onDeleteClick: () => void
+ onChangeCollaborationType: (type: CollaborationType) => void
+}
+
+export const CollaboratorItem = ({
+ email,
+ name,
+ image,
+ type,
+ isGuest = false,
+ isOwner,
+ onDeleteClick,
+ onChangeCollaborationType,
+}: Props) => {
+ const handleEditClick = () =>
+ onChangeCollaborationType(CollaborationType.WRITE)
+ const handleViewClick = () =>
+ onChangeCollaborationType(CollaborationType.READ)
+ return (
+
+
+
+
+ {isOwner && (
+
+
+ {convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
+
+
+ {convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
+
+
+ Remove
+
+
+ )}
+
+ )
+}
+
+export const CollaboratorIdentityContent = ({
+ name,
+ tag,
+ isGuest = false,
+ image,
+ email,
+}: {
+ name?: string
+ tag?: string
+ image?: string
+ isGuest?: boolean
+ email: string
+}) => (
+
+
+
+
+ {name && (
+
+ {name}
+
+ )}
+
+ {email}
+
+
+
+
+ {isGuest && Pending }
+ {tag}
+
+
+)
diff --git a/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/index.tsx b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/index.tsx
new file mode 100644
index 000000000..0616972eb
--- /dev/null
+++ b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/index.tsx
@@ -0,0 +1 @@
+export * from './CollaborationMenuButton'
diff --git a/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx b/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx
index 2f7848e2d..303f0e4e6 100644
--- a/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx
+++ b/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx
@@ -14,6 +14,7 @@ import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useRouter } from 'next/router'
import React from 'react'
import { PublishButton } from '../buttons/PublishButton'
+import { CollaborationMenuButton } from './CollaborationMenuButton'
import { EditableTypebotName } from './EditableTypebotName'
export const headerHeight = 56
@@ -153,6 +154,7 @@ export const TypebotHeader = () => {
+
{router.pathname.includes('/edit') && (
Preview
)}
diff --git a/apps/builder/contexts/TypebotContext/TypebotContext.tsx b/apps/builder/contexts/TypebotContext/TypebotContext.tsx
index c17715cf1..a2fa1b701 100644
--- a/apps/builder/contexts/TypebotContext/TypebotContext.tsx
+++ b/apps/builder/contexts/TypebotContext/TypebotContext.tsx
@@ -19,7 +19,7 @@ import {
checkIfTypebotsAreEqual,
parseDefaultPublicId,
updateTypebot,
-} from 'services/typebots'
+} from 'services/typebots/typebots'
import { fetcher, preventUserFromRefreshing } from 'services/utils'
import useSWR from 'swr'
import { isDefined, isNotDefined } from 'utils'
@@ -33,6 +33,7 @@ import { useDebounce } from 'use-debounce'
import { itemsAction, ItemsActions } from './actions/items'
import { generate } from 'short-uuid'
import { deepEqual } from 'fast-equals'
+import { User } from 'db'
const autoSaveTimeout = 10000
type UpdateTypebotPayload = Partial<{
@@ -48,6 +49,8 @@ const typebotContext = createContext<
{
typebot?: Typebot
publishedTypebot?: PublicTypebot
+ owner?: User
+ isReadOnly?: boolean
isPublished: boolean
isPublishing: boolean
isSavingLoading: boolean
@@ -84,14 +87,16 @@ export const TypebotContext = ({
position: 'top-right',
status: 'error',
})
- const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
- typebotId,
- onError: (error) =>
- toast({
- title: 'Error while fetching typebot',
- description: error.message,
- }),
- })
+
+ const { typebot, publishedTypebot, owner, isReadOnly, isLoading, mutate } =
+ useFetchedTypebot({
+ typebotId,
+ onError: (error) =>
+ toast({
+ title: 'Error while fetching typebot',
+ description: error.message,
+ }),
+ })
useEffect(() => {
if (
@@ -264,6 +269,8 @@ export const TypebotContext = ({
value={{
typebot: localTypebot,
publishedTypebot,
+ owner,
+ isReadOnly,
isSavingLoading,
save: saveTypebot,
undo,
@@ -297,13 +304,20 @@ export const useFetchedTypebot = ({
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
- { typebot: Typebot; publishedTypebot?: PublicTypebot },
+ {
+ typebot: Typebot
+ publishedTypebot?: PublicTypebot
+ owner?: User
+ isReadOnly?: boolean
+ },
Error
>(`/api/typebots/${typebotId}`, fetcher)
if (error) onError(error)
return {
typebot: data?.typebot,
publishedTypebot: data?.publishedTypebot,
+ owner: data?.owner,
+ isReadOnly: data?.isReadOnly,
isLoading: !error && !data,
mutate,
}
diff --git a/apps/builder/contexts/TypebotContext/actions/steps.ts b/apps/builder/contexts/TypebotContext/actions/steps.ts
index d80624ee7..6daa34180 100644
--- a/apps/builder/contexts/TypebotContext/actions/steps.ts
+++ b/apps/builder/contexts/TypebotContext/actions/steps.ts
@@ -5,7 +5,7 @@ import {
DraggableStepType,
StepIndices,
} from 'models'
-import { parseNewStep } from 'services/typebots'
+import { parseNewStep } from 'services/typebots/typebots'
import { removeEmptyBlocks } from './blocks'
import { WritableDraft } from 'immer/dist/types/types-external'
import { SetTypebot } from '../TypebotContext'
diff --git a/apps/builder/contexts/TypebotDndContext.tsx b/apps/builder/contexts/TypebotDndContext.tsx
index dbb6ae81e..f6f7b61c7 100644
--- a/apps/builder/contexts/TypebotDndContext.tsx
+++ b/apps/builder/contexts/TypebotDndContext.tsx
@@ -7,16 +7,21 @@ import {
useEffect,
useState,
} from 'react'
-import { TypebotInDashboard } from 'services/typebots'
+import { TypebotInDashboard } from 'services/typebots/typebots'
const typebotDndContext = createContext<{
draggedTypebot?: TypebotInDashboard
setDraggedTypebot: Dispatch>
mouseOverFolderId?: string | null
setMouseOverFolderId: Dispatch>
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
-}>({})
+}>({
+ setDraggedTypebot: () => {
+ console.log('Not implemented')
+ },
+ setMouseOverFolderId: () => {
+ console.log('Not implemented')
+ },
+})
export const TypebotDndContext = ({ children }: { children: ReactNode }) => {
const [draggedTypebot, setDraggedTypebot] = useState()
diff --git a/apps/builder/contexts/UserContext.tsx b/apps/builder/contexts/UserContext.tsx
index 7ae2f7900..8753a13d7 100644
--- a/apps/builder/contexts/UserContext.tsx
+++ b/apps/builder/contexts/UserContext.tsx
@@ -9,7 +9,7 @@ import {
useState,
} from 'react'
import { isDefined, isNotDefined } from 'utils'
-import { updateUser as updateUserInDb } from 'services/user'
+import { updateUser as updateUserInDb } from 'services/user/user'
import { useToast } from '@chakra-ui/react'
import { deepEqual } from 'fast-equals'
import { User } from 'db'
@@ -56,7 +56,15 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
if (!router.isReady) return
if (status === 'loading') return
if (status === 'unauthenticated' && !isSigningIn())
- router.replace('/signin')
+ router.replace({
+ pathname: '/signin',
+ query:
+ router.pathname !== '/typebots'
+ ? {
+ redirectPath: router.asPath,
+ }
+ : undefined,
+ })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, router])
diff --git a/apps/builder/layouts/dashboard/TemplatesContent.tsx b/apps/builder/layouts/dashboard/TemplatesContent.tsx
index 81b37b863..d714d998a 100644
--- a/apps/builder/layouts/dashboard/TemplatesContent.tsx
+++ b/apps/builder/layouts/dashboard/TemplatesContent.tsx
@@ -13,7 +13,7 @@ import { useUser } from 'contexts/UserContext'
import { Typebot } from 'models'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
-import { createTypebot, importTypebot } from 'services/typebots'
+import { createTypebot, importTypebot } from 'services/typebots/typebots'
import { generate } from 'short-uuid'
export type TemplateProps = { name: string; emoji: string; fileName: string }
diff --git a/apps/builder/layouts/editor/Board.tsx b/apps/builder/layouts/editor/Board.tsx
index f5c970347..208f0d975 100644
--- a/apps/builder/layouts/editor/Board.tsx
+++ b/apps/builder/layouts/editor/Board.tsx
@@ -10,14 +10,14 @@ import { useTypebot } from 'contexts/TypebotContext'
import { Graph } from 'components/shared/Graph'
export const Board = () => {
- const { typebot } = useTypebot()
+ const { typebot, isReadOnly } = useTypebot()
const { rightPanel } = useEditor()
return (
-
+
{typebot && }
{rightPanel === RightPanel.PREVIEW && }
diff --git a/apps/builder/layouts/results/ResultsContent.tsx b/apps/builder/layouts/results/ResultsContent.tsx
index 30898d814..14232ce2c 100644
--- a/apps/builder/layouts/results/ResultsContent.tsx
+++ b/apps/builder/layouts/results/ResultsContent.tsx
@@ -5,7 +5,7 @@ import { useUser } from 'contexts/UserContext'
import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { useStats } from 'services/analytics'
-import { isFreePlan } from 'services/user'
+import { isFreePlan } from 'services/user/user'
import { AnalyticsContent } from './AnalyticsContent'
import { SubmissionsContent } from './SubmissionContent'
diff --git a/apps/builder/layouts/results/SubmissionContent.tsx b/apps/builder/layouts/results/SubmissionContent.tsx
index 93be81b56..be8b6ac6a 100644
--- a/apps/builder/layouts/results/SubmissionContent.tsx
+++ b/apps/builder/layouts/results/SubmissionContent.tsx
@@ -8,7 +8,7 @@ import {
deleteResults,
getAllResults,
useResults,
-} from 'services/results'
+} from 'services/typebots'
import { unparse } from 'papaparse'
import { UnlockProPlanInfo } from 'components/shared/Info'
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 177d3c159..122aeca1b 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -7,8 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
- "test": "dotenv -e ./playwright/.env -e .env.local -- yarn playwright test",
- "test:open": "dotenv -e ./playwright/.env -e .env.local -v PWDEBUG=1 -- yarn playwright test"
+ "test": "yarn playwright test",
+ "test:open": "PWDEBUG=1 yarn playwright test"
},
"msw": {
"workerDirectory": "public"
@@ -40,6 +40,7 @@
"aws-sdk": "^2.1073.0",
"bot-engine": "*",
"browser-image-compression": "^1.0.17",
+ "cuid": "^2.1.8",
"db": "*",
"deep-object-diff": "^1.1.7",
"fast-equals": "^3.0.0",
diff --git a/apps/builder/pages/_app.tsx b/apps/builder/pages/_app.tsx
index 882f8d649..5008052ac 100644
--- a/apps/builder/pages/_app.tsx
+++ b/apps/builder/pages/_app.tsx
@@ -18,7 +18,7 @@ import { actions } from 'libs/kbar'
import { enableMocks } from 'mocks'
import { SupportBubble } from 'components/shared/SupportBubble'
-if (process.env.NEXT_PUBLIC_AUTH_MOCKING === 'enabled') enableMocks()
+if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks()
const App = ({ Component, pageProps }: AppProps) => {
useRouterProgressBar()
diff --git a/apps/builder/pages/api/auth/[...nextauth].ts b/apps/builder/pages/api/auth/[...nextauth].ts
index bf492a270..0a7470103 100644
--- a/apps/builder/pages/api/auth/[...nextauth].ts
+++ b/apps/builder/pages/api/auth/[...nextauth].ts
@@ -1,5 +1,4 @@
import NextAuth from 'next-auth'
-import { PrismaAdapter } from '@next-auth/prisma-adapter'
import EmailProvider from 'next-auth/providers/email'
import GitHubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'
@@ -7,10 +6,8 @@ import FacebookProvider from 'next-auth/providers/facebook'
import prisma from 'libs/prisma'
import { Provider } from 'next-auth/providers'
import { NextApiRequest, NextApiResponse } from 'next'
-import { isNotDefined } from 'utils'
-import { User } from 'db'
-import { randomUUID } from 'crypto'
import { withSentry } from '@sentry/nextjs'
+import { CustomAdapter } from './adapter'
const providers: Provider[] = [
EmailProvider({
@@ -52,30 +49,19 @@ if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET)
const handler = (req: NextApiRequest, res: NextApiResponse) => {
NextAuth(req, res, {
- adapter: PrismaAdapter(prisma),
+ adapter: CustomAdapter(prisma),
secret: process.env.ENCRYPTION_SECRET,
providers,
session: {
strategy: 'database',
},
callbacks: {
- session: async ({ session, user }) => {
- const userFromDb = user as User
- if (isNotDefined(userFromDb.apiToken))
- userFromDb.apiToken = await generateApiToken(userFromDb.id)
- return { ...session, user: userFromDb }
- },
+ session: async ({ session, user }) => ({
+ ...session,
+ user,
+ }),
},
})
}
-const generateApiToken = async (userId: string) => {
- const apiToken = randomUUID()
- await prisma.user.update({
- where: { id: userId },
- data: { apiToken },
- })
- return apiToken
-}
-
export default withSentry(handler)
diff --git a/apps/builder/pages/api/auth/adapter.ts b/apps/builder/pages/api/auth/adapter.ts
new file mode 100644
index 000000000..353ac7e48
--- /dev/null
+++ b/apps/builder/pages/api/auth/adapter.ts
@@ -0,0 +1,79 @@
+// Forked from https://github.com/nextauthjs/adapters/blob/main/packages/prisma/src/index.ts
+import type { PrismaClient, Prisma, Invitation } from 'db'
+import { randomUUID } from 'crypto'
+import type { Adapter, AdapterUser } from 'next-auth/adapters'
+import cuid from 'cuid'
+
+export function CustomAdapter(p: PrismaClient): Adapter {
+ return {
+ createUser: async (data: Omit) => {
+ const user = { id: cuid(), email: data.email as string }
+ const invitations = await p.invitation.findMany({
+ where: { email: user.email },
+ })
+ const createdUser = await p.user.create({
+ data: { ...data, id: user.id, apiToken: randomUUID() },
+ })
+ if (invitations.length > 0)
+ await convertInvitationsToCollaborations(p, user, invitations)
+ return createdUser
+ },
+ getUser: (id) => p.user.findUnique({ where: { id } }),
+ getUserByEmail: (email) => p.user.findUnique({ where: { email } }),
+ async getUserByAccount(provider_providerAccountId) {
+ const account = await p.account.findUnique({
+ where: { provider_providerAccountId },
+ select: { user: true },
+ })
+ return account?.user ?? null
+ },
+ updateUser: (data) => p.user.update({ where: { id: data.id }, data }),
+ deleteUser: (id) => p.user.delete({ where: { id } }),
+ linkAccount: (data) => p.account.create({ data }) as any,
+ unlinkAccount: (provider_providerAccountId) =>
+ p.account.delete({ where: { provider_providerAccountId } }) as any,
+ async getSessionAndUser(sessionToken) {
+ const userAndSession = await p.session.findUnique({
+ where: { sessionToken },
+ include: { user: true },
+ })
+ if (!userAndSession) return null
+ const { user, ...session } = userAndSession
+ return { user, session }
+ },
+ createSession: (data) => p.session.create({ data }),
+ updateSession: (data) =>
+ p.session.update({ data, where: { sessionToken: data.sessionToken } }),
+ deleteSession: (sessionToken) =>
+ p.session.delete({ where: { sessionToken } }),
+ createVerificationToken: (data) => p.verificationToken.create({ data }),
+ async useVerificationToken(identifier_token) {
+ try {
+ return await p.verificationToken.delete({ where: { identifier_token } })
+ } catch (error) {
+ if ((error as Prisma.PrismaClientKnownRequestError).code === 'P2025')
+ return null
+ throw error
+ }
+ },
+ }
+}
+
+const convertInvitationsToCollaborations = async (
+ p: PrismaClient,
+ { id, email }: { id: string; email: string },
+ invitations: Invitation[]
+) => {
+ await p.collaboratorsOnTypebots.createMany({
+ data: invitations.map((invitation) => ({
+ typebotId: invitation.typebotId,
+ type: invitation.type,
+ userId: id,
+ })),
+ })
+ return p.invitation.deleteMany({
+ where: {
+ email,
+ },
+ })
+}
diff --git a/apps/builder/pages/api/typebots.ts b/apps/builder/pages/api/typebots.ts
index 205e06e5c..912e5f5c5 100644
--- a/apps/builder/pages/api/typebots.ts
+++ b/apps/builder/pages/api/typebots.ts
@@ -3,7 +3,7 @@ import { Prisma, User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
-import { parseNewTypebot } from 'services/typebots'
+import { parseNewTypebot } from 'services/typebots/typebots'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
diff --git a/apps/builder/pages/api/typebots/[typebotId].ts b/apps/builder/pages/api/typebots/[typebotId].ts
index b8df0b30e..8f7a280af 100644
--- a/apps/builder/pages/api/typebots/[typebotId].ts
+++ b/apps/builder/pages/api/typebots/[typebotId].ts
@@ -1,5 +1,5 @@
import { withSentry } from '@sentry/nextjs'
-import { User } from 'db'
+import { CollaborationType, Prisma, User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
@@ -17,33 +17,37 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = session.user as User
if (req.method === 'GET') {
const typebot = await prisma.typebot.findFirst({
- where: {
- id: typebotId,
- ownerId: user.email === adminEmail ? undefined : user.id,
- },
+ where: parseWhereFilter(typebotId, user, 'read'),
include: {
publishedTypebot: true,
+ owner: { select: { email: true, name: true, image: true } },
+ collaborators: { select: { userId: true, type: true } },
},
})
if (!typebot) return res.send({ typebot: null })
- const { publishedTypebot, ...restOfTypebot } = typebot
- return res.send({ typebot: restOfTypebot, publishedTypebot })
+ const { publishedTypebot, owner, collaborators, ...restOfTypebot } = typebot
+ const isReadOnly =
+ collaborators.find((c) => c.userId === user.id)?.type ===
+ CollaborationType.READ
+ return res.send({
+ typebot: restOfTypebot,
+ publishedTypebot,
+ owner,
+ isReadOnly,
+ })
}
+
+ const canEditTypebot = parseWhereFilter(typebotId, user, 'write')
if (req.method === 'DELETE') {
- const typebots = await prisma.typebot.delete({
- where: {
- id_ownerId: {
- id: typebotId,
- ownerId: user.id,
- },
- },
+ const typebots = await prisma.typebot.deleteMany({
+ where: canEditTypebot,
})
return res.send({ typebots })
}
if (req.method === 'PUT') {
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
- const typebots = await prisma.typebot.update({
- where: { id_ownerId: { id: typebotId, ownerId: user.id } },
+ const typebots = await prisma.typebot.updateMany({
+ where: canEditTypebot,
data: {
...data,
theme: data.theme ?? undefined,
@@ -54,8 +58,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (req.method === 'PATCH') {
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
- const typebots = await prisma.typebot.update({
- where: { id_ownerId: { id: typebotId, ownerId: user.id } },
+ const typebots = await prisma.typebot.updateMany({
+ where: canEditTypebot,
data,
})
return res.send({ typebots })
@@ -63,4 +67,26 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res)
}
+const parseWhereFilter = (
+ typebotId: string,
+ user: User,
+ type: 'read' | 'write'
+): Prisma.TypebotWhereInput => ({
+ OR: [
+ {
+ id: typebotId,
+ ownerId: user.email === adminEmail ? undefined : user.id,
+ },
+ {
+ id: typebotId,
+ collaborators: {
+ every: {
+ userId: user.id,
+ type: type === 'write' ? CollaborationType.WRITE : undefined,
+ },
+ },
+ },
+ ],
+})
+
export default withSentry(handler)
diff --git a/apps/builder/pages/api/typebots/[typebotId]/collaborators copy/[userId].ts b/apps/builder/pages/api/typebots/[typebotId]/collaborators copy/[userId].ts
new file mode 100644
index 000000000..7b617bdd9
--- /dev/null
+++ b/apps/builder/pages/api/typebots/[typebotId]/collaborators copy/[userId].ts
@@ -0,0 +1,34 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getAuthenticatedUser } from 'services/api/utils'
+import { methodNotAllowed, notAuthenticated } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = await getAuthenticatedUser(req)
+ if (!user) return notAuthenticated(res)
+ const typebotId = req.query.typebotId as string
+ const userId = req.query.userId as string
+ if (req.method === 'PUT') {
+ const data = req.body
+ await prisma.collaboratorsOnTypebots.upsert({
+ where: { userId_typebotId: { typebotId, userId } },
+ create: data,
+ update: data,
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ if (req.method === 'DELETE') {
+ await prisma.collaboratorsOnTypebots.delete({
+ where: { userId_typebotId: { typebotId, userId } },
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/builder/pages/api/typebots/[typebotId]/collaborators.ts b/apps/builder/pages/api/typebots/[typebotId]/collaborators.ts
new file mode 100644
index 000000000..540329cf7
--- /dev/null
+++ b/apps/builder/pages/api/typebots/[typebotId]/collaborators.ts
@@ -0,0 +1,23 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getAuthenticatedUser } from 'services/api/utils'
+import { methodNotAllowed, notAuthenticated } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = await getAuthenticatedUser(req)
+ if (!user) return notAuthenticated(res)
+ const typebotId = req.query.typebotId as string
+ if (req.method === 'GET') {
+ const collaborators = await prisma.collaboratorsOnTypebots.findMany({
+ where: { typebotId },
+ include: { user: { select: { name: true, image: true, email: true } } },
+ })
+ return res.send({
+ collaborators,
+ })
+ }
+ methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts b/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts
new file mode 100644
index 000000000..7b617bdd9
--- /dev/null
+++ b/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts
@@ -0,0 +1,34 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getAuthenticatedUser } from 'services/api/utils'
+import { methodNotAllowed, notAuthenticated } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = await getAuthenticatedUser(req)
+ if (!user) return notAuthenticated(res)
+ const typebotId = req.query.typebotId as string
+ const userId = req.query.userId as string
+ if (req.method === 'PUT') {
+ const data = req.body
+ await prisma.collaboratorsOnTypebots.upsert({
+ where: { userId_typebotId: { typebotId, userId } },
+ create: data,
+ update: data,
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ if (req.method === 'DELETE') {
+ await prisma.collaboratorsOnTypebots.delete({
+ where: { userId_typebotId: { typebotId, userId } },
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/builder/pages/api/typebots/[typebotId]/invitations.ts b/apps/builder/pages/api/typebots/[typebotId]/invitations.ts
new file mode 100644
index 000000000..a439d61e8
--- /dev/null
+++ b/apps/builder/pages/api/typebots/[typebotId]/invitations.ts
@@ -0,0 +1,58 @@
+import { withSentry } from '@sentry/nextjs'
+import { invitationToCollaborate } from 'assets/emails/invitationToCollaborate'
+import { CollaborationType } from 'db'
+import prisma from 'libs/prisma'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { sendEmailNotification } from 'services/api/emails'
+import { getAuthenticatedUser } from 'services/api/utils'
+import {
+ badRequest,
+ isNotDefined,
+ methodNotAllowed,
+ notAuthenticated,
+} from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = await getAuthenticatedUser(req)
+ if (!user) return notAuthenticated(res)
+ const typebotId = req.query.typebotId as string
+ if (req.method === 'GET') {
+ const invitations = await prisma.invitation.findMany({
+ where: { typebotId },
+ })
+ return res.send({
+ invitations,
+ })
+ }
+ if (req.method === 'POST') {
+ const { email, type } =
+ (req.body as
+ | { email: string | undefined; type: CollaborationType | undefined }
+ | undefined) ?? {}
+ if (!email || !type) return badRequest(res)
+ const existingUser = await prisma.user.findUnique({
+ where: { email },
+ select: { id: true },
+ })
+ if (existingUser)
+ await prisma.collaboratorsOnTypebots.create({
+ data: { type, typebotId, userId: existingUser.id },
+ })
+ else await prisma.invitation.create({ data: { email, type, typebotId } })
+ if (isNotDefined(process.env.NEXT_PUBLIC_E2E_TEST))
+ await sendEmailNotification({
+ to: email,
+ subject: "You've been invited to collaborate 🤝",
+ content: invitationToCollaborate(
+ user.email ?? '',
+ `${process.env.NEXTAUTH_URL}/typebots/shared`
+ ),
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts b/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts
new file mode 100644
index 000000000..010a97a36
--- /dev/null
+++ b/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts
@@ -0,0 +1,34 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getAuthenticatedUser } from 'services/api/utils'
+import { methodNotAllowed, notAuthenticated } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const user = await getAuthenticatedUser(req)
+ if (!user) return notAuthenticated(res)
+ const typebotId = req.query.typebotId as string
+ const email = req.query.email as string
+ if (req.method === 'PUT') {
+ const data = req.body
+ await prisma.invitation.upsert({
+ where: { email_typebotId: { email, typebotId } },
+ create: data,
+ update: data,
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ if (req.method === 'DELETE') {
+ await prisma.invitation.delete({
+ where: { email_typebotId: { email, typebotId } },
+ })
+ return res.send({
+ message: 'success',
+ })
+ }
+ methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/builder/pages/api/typebots/[typebotId]/results.ts b/apps/builder/pages/api/typebots/[typebotId]/results.ts
index 8d83247bb..8642ffd17 100644
--- a/apps/builder/pages/api/typebots/[typebotId]/results.ts
+++ b/apps/builder/pages/api/typebots/[typebotId]/results.ts
@@ -3,7 +3,7 @@ import { User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
-import { isFreePlan } from 'services/user'
+import { isFreePlan } from 'services/user/user'
import { methodNotAllowed } from 'utils'
const adminEmail = 'contact@baptiste-arnaud.fr'
diff --git a/apps/builder/pages/api/users/[id]/sharedTypebots.ts b/apps/builder/pages/api/users/[id]/sharedTypebots.ts
new file mode 100644
index 000000000..3665477b1
--- /dev/null
+++ b/apps/builder/pages/api/users/[id]/sharedTypebots.ts
@@ -0,0 +1,31 @@
+import { withSentry } from '@sentry/nextjs'
+import prisma from 'libs/prisma'
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getAuthenticatedUser } from 'services/api/utils'
+import { methodNotAllowed, notAuthenticated } from 'utils'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === 'GET') {
+ const user = await getAuthenticatedUser(req)
+ if (!user) return notAuthenticated(res)
+ const isCountOnly = req.query.count as string | undefined
+ if (isCountOnly) {
+ const count = await prisma.collaboratorsOnTypebots.count({
+ where: { userId: user.id },
+ })
+ return res.send({ count })
+ }
+ const sharedTypebots = await prisma.collaboratorsOnTypebots.findMany({
+ where: { userId: user.id },
+ include: {
+ typebot: { select: { name: true, publishedTypebotId: true, id: true } },
+ },
+ })
+ return res.send({
+ sharedTypebots: sharedTypebots.map((typebot) => ({ ...typebot.typebot })),
+ })
+ }
+ methodNotAllowed(res)
+}
+
+export default withSentry(handler)
diff --git a/apps/builder/pages/typebots.tsx b/apps/builder/pages/typebots.tsx
index 6cf634aab..ca63e608a 100644
--- a/apps/builder/pages/typebots.tsx
+++ b/apps/builder/pages/typebots.tsx
@@ -13,7 +13,7 @@ import { Banner } from 'components/dashboard/annoucements/AnnoucementBanner'
const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false)
- const { query, isReady } = useRouter()
+ const { query, isReady, push } = useRouter()
const { user } = useUser()
const toast = useToast({
position: 'top-right',
@@ -35,17 +35,20 @@ const DashboardPage = () => {
if (!isReady) return
const couponCode = query.coupon?.toString()
const stripeStatus = query.stripe?.toString()
+ const redirectPath = query.redirectPath as string | undefined
if (stripeStatus === 'success')
toast({
title: 'Typebot Pro',
description: "You've successfully subscribed 🎉",
})
- if (!couponCode) return
- setIsLoading(true)
- redeemCoupon(couponCode).then(() => {
- location.href = '/typebots'
- })
+ if (couponCode) {
+ setIsLoading(true)
+ redeemCoupon(couponCode).then(() => {
+ location.href = '/typebots'
+ })
+ }
+ if (redirectPath) push(redirectPath)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady])
diff --git a/apps/builder/pages/typebots/shared.tsx b/apps/builder/pages/typebots/shared.tsx
new file mode 100644
index 000000000..40584714f
--- /dev/null
+++ b/apps/builder/pages/typebots/shared.tsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import { Flex, Heading, Stack } from '@chakra-ui/layout'
+import { DashboardHeader } from 'components/dashboard/DashboardHeader'
+import { Seo } from 'components/Seo'
+import { BackButton } from 'components/dashboard/FolderContent/BackButton'
+import { useSharedTypebots } from 'services/user/sharedTypebots'
+import { useUser } from 'contexts/UserContext'
+import { useToast, Wrap } from '@chakra-ui/react'
+import { ButtonSkeleton } from 'components/dashboard/FolderContent/FolderButton'
+import { TypebotButton } from 'components/dashboard/FolderContent/TypebotButton'
+
+const SharedTypebotsPage = () => {
+ const { user } = useUser()
+ const toast = useToast({
+ position: 'top-right',
+ status: 'error',
+ })
+ const { sharedTypebots, isLoading } = useSharedTypebots({
+ userId: user?.id,
+ onError: (e) =>
+ toast({ title: "Couldn't fetch shared bots", description: e.message }),
+ })
+ return (
+
+
+
+
+
+ Shared with me
+
+
+
+
+
+ {isLoading && }
+ {sharedTypebots?.map((typebot) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+export default SharedTypebotsPage
diff --git a/apps/builder/playwright.config.ts b/apps/builder/playwright.config.ts
index d8cbae3ac..bb7835779 100644
--- a/apps/builder/playwright.config.ts
+++ b/apps/builder/playwright.config.ts
@@ -1,6 +1,13 @@
import { devices, PlaywrightTestConfig } from '@playwright/test'
import path from 'path'
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+require('dotenv').config({
+ path: path.join(__dirname, 'playwright/.env'),
+})
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+require('dotenv').config({ path: path.join(__dirname, '.env.local') })
+
const config: PlaywrightTestConfig = {
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
testDir: path.join(__dirname, 'playwright/tests'),
diff --git a/apps/builder/playwright/freeUser.json b/apps/builder/playwright/freeUser.json
index 2374e3bdf..470646c1e 100644
--- a/apps/builder/playwright/freeUser.json
+++ b/apps/builder/playwright/freeUser.json
@@ -6,7 +6,11 @@
"localStorage": [
{
"name": "authenticatedUser",
- "value": "{\"id\":\"freeUser\",\"name\":\"John Smith\",\"email\":\"john@smith.fr\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"FREE\",\"stripeId\":null}"
+ "value": "{\"id\":\"freeUser\",\"name\":\"Free user\",\"email\":\"free-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"FREE\",\"stripeId\":null}"
+ },
+ {
+ "name": "typebot-20-modal",
+ "value": "hide"
}
]
}
diff --git a/apps/builder/playwright/proUser.json b/apps/builder/playwright/proUser.json
index 172b6a550..9eae6d8b7 100644
--- a/apps/builder/playwright/proUser.json
+++ b/apps/builder/playwright/proUser.json
@@ -6,7 +6,11 @@
"localStorage": [
{
"name": "authenticatedUser",
- "value": "{\"id\":\"proUser\",\"name\":\"John Smith\",\"email\":\"john@smith.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"PRO\",\"stripeId\":null}"
+ "value": "{\"id\":\"proUser\",\"name\":\"Pro user\",\"email\":\"pro-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"PRO\",\"stripeId\":null}"
+ },
+ {
+ "name": "typebot-20-modal",
+ "value": "hide"
}
]
}
diff --git a/apps/builder/playwright/services/database.ts b/apps/builder/playwright/services/database.ts
index 530dd4c95..78589fecc 100644
--- a/apps/builder/playwright/services/database.ts
+++ b/apps/builder/playwright/services/database.ts
@@ -8,7 +8,7 @@ import {
Step,
Typebot,
} from 'models'
-import { DashboardFolder, PrismaClient, User } from 'db'
+import { CollaborationType, DashboardFolder, PrismaClient, User } from 'db'
import { readFileSync } from 'fs'
import { encrypt } from 'utils'
@@ -39,6 +39,13 @@ export const createUsers = () =>
],
})
+export const createCollaboration = (
+ userId: string,
+ typebotId: string,
+ type: CollaborationType
+) =>
+ prisma.collaboratorsOnTypebots.create({ data: { userId, typebotId, type } })
+
export const getSignedInUser = (email: string) =>
prisma.user.findFirst({ where: { email } })
diff --git a/apps/builder/playwright/tests/collaboration.spec.ts b/apps/builder/playwright/tests/collaboration.spec.ts
new file mode 100644
index 000000000..0f3afc6ef
--- /dev/null
+++ b/apps/builder/playwright/tests/collaboration.spec.ts
@@ -0,0 +1,71 @@
+import test, { expect } from '@playwright/test'
+import { InputStepType, defaultTextInputOptions } from 'models'
+import path from 'path'
+import { generate } from 'short-uuid'
+import { createTypebots, parseDefaultBlockWithStep } from '../services/database'
+
+const typebotId = generate()
+
+test.beforeAll(async () => {
+ await createTypebots([
+ {
+ id: typebotId,
+ name: 'Shared typebot',
+ ownerId: 'freeUser',
+ ...parseDefaultBlockWithStep({
+ type: InputStepType.TEXT,
+ options: defaultTextInputOptions,
+ }),
+ },
+ ])
+})
+
+test.describe('Typebot owner', () => {
+ test.use({
+ storageState: path.join(__dirname, '../freeUser.json'),
+ })
+ test('Can invite collaborators', async ({ page }) => {
+ await page.goto(`/typebots/${typebotId}/edit`)
+ await page.click('button[aria-label="Show collaboration menu"]')
+ await expect(page.locator('text=Free user')).toBeHidden()
+ await page.fill(
+ 'input[placeholder="colleague@company.com"]',
+ 'guest@email.com'
+ )
+ await page.click('text=Can view')
+ await page.click('text=Can edit')
+ await page.click('text=Invite')
+ await expect(page.locator('text=Pending')).toBeVisible()
+ await expect(page.locator('text=Free user')).toBeHidden()
+ await page.fill(
+ 'input[placeholder="colleague@company.com"]',
+ 'pro-user@email.com'
+ )
+ await page.click('text=Can edit')
+ await page.click('text=Can view')
+ await page.click('text=Invite')
+ await expect(page.locator('text=Free user')).toBeVisible()
+ await expect(page.locator('text=Pro user')).toBeVisible()
+ await page.click('text="guest@email.com"')
+ await page.click('text="Remove"')
+ await expect(page.locator('text="guest@email.com"')).toBeHidden()
+ })
+})
+
+test.describe('Collaborator', () => {
+ test('should display shared typebots', async ({ page }) => {
+ await page.goto('/typebots')
+ await expect(page.locator('text=Shared')).toBeVisible()
+ await page.click('text=Shared')
+ await page.waitForNavigation()
+ expect(page.url()).toMatch('/typebots/shared')
+ await expect(page.locator('text="Shared typebot"')).toBeVisible()
+ await page.click('text=Shared typebot')
+ await page.click('button[aria-label="Show collaboration menu"]')
+ await page.click('text=Pro user')
+ await expect(page.locator('text="Remove"')).toBeHidden()
+ await expect(page.locator('text=Free user')).toBeVisible()
+ await page.click('text=Block #1', { force: true })
+ await expect(page.locator('input[value="Block #1"]')).toBeHidden()
+ })
+})
diff --git a/apps/builder/services/api/emails.ts b/apps/builder/services/api/emails.ts
new file mode 100644
index 000000000..3eea2e456
--- /dev/null
+++ b/apps/builder/services/api/emails.ts
@@ -0,0 +1,27 @@
+import { createTransport } from 'nodemailer'
+
+export const sendEmailNotification = ({
+ to,
+ subject,
+ content,
+}: {
+ to: string
+ subject: string
+ content: string
+}) => {
+ const transporter = createTransport({
+ host: process.env.AUTH_EMAIL_SERVER_HOST,
+ port: Number(process.env.AUTH_EMAIL_SERVER_PORT),
+ auth: {
+ user: process.env.AUTH_EMAIL_SERVER_USER,
+ pass: process.env.AUTH_EMAIL_SERVER_PASSWORD,
+ },
+ })
+
+ return transporter.sendMail({
+ from: `"${process.env.AUTH_EMAIL_FROM_NAME}" <${process.env.AUTH_EMAIL_FROM_EMAIL}>`,
+ to,
+ subject,
+ html: content,
+ })
+}
diff --git a/apps/builder/services/api/utils.ts b/apps/builder/services/api/utils.ts
new file mode 100644
index 000000000..454373372
--- /dev/null
+++ b/apps/builder/services/api/utils.ts
@@ -0,0 +1,10 @@
+import { User } from 'db'
+import { NextApiRequest } from 'next'
+import { getSession } from 'next-auth/react'
+
+export const getAuthenticatedUser = async (
+ req: NextApiRequest
+): Promise => {
+ const session = await getSession({ req })
+ return session?.user as User | undefined
+}
diff --git a/apps/builder/services/folders.ts b/apps/builder/services/folders.ts
index c91a0b9e4..80819b700 100644
--- a/apps/builder/services/folders.ts
+++ b/apps/builder/services/folders.ts
@@ -15,7 +15,7 @@ export const useFolders = ({
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
`/api/folders?${params}`,
fetcher,
- { dedupingInterval: 0 }
+ { dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined }
)
if (error) onError(error)
return {
diff --git a/apps/builder/services/typebots/collaborators.ts b/apps/builder/services/typebots/collaborators.ts
new file mode 100644
index 000000000..899103cc7
--- /dev/null
+++ b/apps/builder/services/typebots/collaborators.ts
@@ -0,0 +1,48 @@
+import { CollaboratorsOnTypebots } from 'db'
+import { fetcher } from 'services/utils'
+import useSWR from 'swr'
+import { sendRequest } from 'utils'
+
+export type Collaborator = CollaboratorsOnTypebots & {
+ user: {
+ name: string | null
+ image: string | null
+ email: string | null
+ }
+}
+
+export const useCollaborators = ({
+ typebotId,
+ onError,
+}: {
+ typebotId?: string
+ onError: (error: Error) => void
+}) => {
+ const { data, error, mutate } = useSWR<
+ { collaborators: Collaborator[] },
+ Error
+ >(typebotId ? `/api/typebots/${typebotId}/collaborators` : null, fetcher)
+ if (error) onError(error)
+ return {
+ collaborators: data?.collaborators,
+ isLoading: !error && !data,
+ mutate,
+ }
+}
+
+export const updateCollaborator = (
+ typebotId: string,
+ userId: string,
+ updates: Partial
+) =>
+ sendRequest({
+ method: 'PUT',
+ url: `/api/typebots/${typebotId}/collaborators/${userId}`,
+ body: updates,
+ })
+
+export const deleteCollaborator = (typebotId: string, userId: string) =>
+ sendRequest({
+ method: 'DELETE',
+ url: `/api/typebots/${typebotId}/collaborators/${userId}`,
+ })
diff --git a/apps/builder/services/typebots/index.ts b/apps/builder/services/typebots/index.ts
new file mode 100644
index 000000000..d8e9c06f8
--- /dev/null
+++ b/apps/builder/services/typebots/index.ts
@@ -0,0 +1,2 @@
+export * from './results'
+export * from './typebots'
diff --git a/apps/builder/services/typebots/invitations.ts b/apps/builder/services/typebots/invitations.ts
new file mode 100644
index 000000000..09d09b011
--- /dev/null
+++ b/apps/builder/services/typebots/invitations.ts
@@ -0,0 +1,51 @@
+import { CollaborationType, Invitation } from 'db'
+import { fetcher } from 'services/utils'
+import useSWR from 'swr'
+import { sendRequest } from 'utils'
+
+export const useInvitations = ({
+ typebotId,
+ onError,
+}: {
+ typebotId?: string
+ onError: (error: Error) => void
+}) => {
+ const { data, error, mutate } = useSWR<{ invitations: Invitation[] }, Error>(
+ typebotId ? `/api/typebots/${typebotId}/invitations` : null,
+ fetcher,
+ { dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined }
+ )
+ if (error) onError(error)
+ return {
+ invitations: data?.invitations,
+ isLoading: !error && !data,
+ mutate,
+ }
+}
+
+export const sendInvitation = (
+ typebotId: string,
+ { email, type }: { email: string; type: CollaborationType }
+) =>
+ sendRequest({
+ method: 'POST',
+ url: `/api/typebots/${typebotId}/invitations`,
+ body: { email, type },
+ })
+
+export const updateInvitation = (
+ typebotId: string,
+ userId: string,
+ updates: Partial
+) =>
+ sendRequest({
+ method: 'PUT',
+ url: `/api/typebots/${typebotId}/invitations/${userId}`,
+ body: updates,
+ })
+
+export const deleteInvitation = (typebotId: string, userId: string) =>
+ sendRequest({
+ method: 'DELETE',
+ url: `/api/typebots/${typebotId}/invitations/${userId}`,
+ })
diff --git a/apps/builder/services/results.ts b/apps/builder/services/typebots/results.ts
similarity index 98%
rename from apps/builder/services/results.ts
rename to apps/builder/services/typebots/results.ts
index fe181ef93..91d41f5e0 100644
--- a/apps/builder/services/results.ts
+++ b/apps/builder/services/typebots/results.ts
@@ -1,9 +1,9 @@
import { ResultWithAnswers, VariableWithValue } from 'models'
import useSWRInfinite from 'swr/infinite'
-import { fetcher } from './utils'
import { stringify } from 'qs'
import { Answer } from 'db'
import { isDefined, sendRequest } from 'utils'
+import { fetcher } from 'services/utils'
const paginationLimit = 50
diff --git a/apps/builder/services/typebots.ts b/apps/builder/services/typebots/typebots.ts
similarity index 96%
rename from apps/builder/services/typebots.ts
rename to apps/builder/services/typebots/typebots.ts
index 5204f22c8..4de3e04f6 100644
--- a/apps/builder/services/typebots.ts
+++ b/apps/builder/services/typebots/typebots.ts
@@ -39,7 +39,7 @@ import {
import shortId, { generate } from 'short-uuid'
import { Typebot } from 'models'
import useSWR from 'swr'
-import { fetcher, omit, toKebabCase } from './utils'
+import { fetcher, omit, toKebabCase } from '../utils'
import {
isBubbleStepType,
stepTypeHasItems,
@@ -49,7 +49,7 @@ import {
import { deepEqual } from 'fast-equals'
import { stringify } from 'qs'
import { isChoiceInput, isConditionStep, sendRequest } from 'utils'
-import { parseBlocksToPublicBlocks } from './publicTypebot'
+import { parseBlocksToPublicBlocks } from '../publicTypebot'
export type TypebotInDashboard = Pick<
Typebot,
@@ -66,7 +66,9 @@ export const useTypebots = ({
const { data, error, mutate } = useSWR<
{ typebots: TypebotInDashboard[] },
Error
- >(`/api/typebots?${params}`, fetcher, { dedupingInterval: 0 })
+ >(`/api/typebots?${params}`, fetcher, {
+ dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
+ })
if (error) onError(error)
return {
typebots: data?.typebots,
@@ -109,7 +111,6 @@ export const duplicateTypebot = async (typebotId: string) => {
},
'id'
)
- console.log(duplicatedTypebot)
return sendRequest({
url: `/api/typebots`,
method: 'POST',
diff --git a/apps/builder/services/credentials.ts b/apps/builder/services/user/credentials.ts
similarity index 96%
rename from apps/builder/services/credentials.ts
rename to apps/builder/services/user/credentials.ts
index 26f807705..6cd3c608a 100644
--- a/apps/builder/services/credentials.ts
+++ b/apps/builder/services/user/credentials.ts
@@ -1,7 +1,7 @@
import { Credentials } from 'models'
import useSWR from 'swr'
import { sendRequest } from 'utils'
-import { fetcher } from './utils'
+import { fetcher } from '../utils'
export const useCredentials = ({
userId,
diff --git a/apps/builder/services/customDomains.ts b/apps/builder/services/user/customDomains.ts
similarity index 96%
rename from apps/builder/services/customDomains.ts
rename to apps/builder/services/user/customDomains.ts
index dfb73a3cf..81fdbdee8 100644
--- a/apps/builder/services/customDomains.ts
+++ b/apps/builder/services/user/customDomains.ts
@@ -2,7 +2,7 @@ import { CustomDomain } from 'db'
import { Credentials } from 'models'
import useSWR from 'swr'
import { sendRequest } from 'utils'
-import { fetcher } from './utils'
+import { fetcher } from '../utils'
export const useCustomDomains = ({
userId,
diff --git a/apps/builder/services/user/index.ts b/apps/builder/services/user/index.ts
new file mode 100644
index 000000000..a9f712d76
--- /dev/null
+++ b/apps/builder/services/user/index.ts
@@ -0,0 +1,3 @@
+export * from './user'
+export * from './customDomains'
+export * from './credentials'
diff --git a/apps/builder/services/user/sharedTypebots.ts b/apps/builder/services/user/sharedTypebots.ts
new file mode 100644
index 000000000..6ad89bdf3
--- /dev/null
+++ b/apps/builder/services/user/sharedTypebots.ts
@@ -0,0 +1,42 @@
+import { Typebot } from 'models'
+import { fetcher } from 'services/utils'
+import useSWR from 'swr'
+import { isNotDefined } from 'utils'
+
+export const useSharedTypebotsCount = ({
+ userId,
+ onError,
+}: {
+ userId?: string
+ onError: (error: Error) => void
+}) => {
+ const { data, error, mutate } = useSWR<{ count: number }, Error>(
+ userId ? `/api/users/${userId}/sharedTypebots?count=true` : null,
+ fetcher
+ )
+ if (error) onError(error)
+ return {
+ totalSharedTypebots: data?.count ?? 0,
+ isLoading: !error && isNotDefined(data?.count),
+ mutate,
+ }
+}
+
+export const useSharedTypebots = ({
+ userId,
+ onError,
+}: {
+ userId?: string
+ onError: (error: Error) => void
+}) => {
+ const { data, error, mutate } = useSWR<
+ { sharedTypebots: Pick[] },
+ Error
+ >(userId ? `/api/users/${userId}/sharedTypebots` : null, fetcher)
+ if (error) onError(error)
+ return {
+ sharedTypebots: data?.sharedTypebots,
+ isLoading: !error && isNotDefined(data),
+ mutate,
+ }
+}
diff --git a/apps/builder/services/user.ts b/apps/builder/services/user/user.ts
similarity index 100%
rename from apps/builder/services/user.ts
rename to apps/builder/services/user/user.ts
diff --git a/package.json b/package.json
index 3123298cd..5ae4d3a86 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"docker:up": "docker-compose up -d",
"db:nuke": "docker-compose down --volumes --remove-orphans",
"dev": "yarn docker:up && turbo run dev --parallel",
- "dev:mocking": "yarn docker:up && NEXT_PUBLIC_AUTH_MOCKING=enabled turbo run dev --parallel",
+ "dev:mocking": "yarn docker:up && NEXT_PUBLIC_E2E_TEST=enabled turbo run dev --parallel",
"build": "yarn docker:up && turbo run build",
"test:builder": "cd apps/builder && yarn test",
"lint": "turbo run lint"
diff --git a/packages/db/prisma/migrations/20220224101209_add_collaboration/migration.sql b/packages/db/prisma/migrations/20220224101209_add_collaboration/migration.sql
new file mode 100644
index 000000000..183af53fa
--- /dev/null
+++ b/packages/db/prisma/migrations/20220224101209_add_collaboration/migration.sql
@@ -0,0 +1,31 @@
+-- CreateEnum
+CREATE TYPE "CollaborationType" AS ENUM ('READ', 'WRITE');
+
+-- CreateTable
+CREATE TABLE "Invitation" (
+ "email" TEXT NOT NULL,
+ "typebotId" TEXT NOT NULL,
+ "type" "CollaborationType" NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "CollaboratorsOnTypebots" (
+ "userId" TEXT NOT NULL,
+ "typebotId" TEXT NOT NULL,
+ "type" "CollaborationType" NOT NULL
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Invitation_email_typebotId_key" ON "Invitation"("email", "typebotId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CollaboratorsOnTypebots_userId_typebotId_key" ON "CollaboratorsOnTypebots"("userId", "typebotId");
+
+-- AddForeignKey
+ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_typebotId_fkey" FOREIGN KEY ("typebotId") REFERENCES "Typebot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CollaboratorsOnTypebots" ADD CONSTRAINT "CollaboratorsOnTypebots_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CollaboratorsOnTypebots" ADD CONSTRAINT "CollaboratorsOnTypebots_typebotId_fkey" FOREIGN KEY ("typebotId") REFERENCES "Typebot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 73ac9d477..0a9c91b83 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -38,20 +38,21 @@ model Session {
}
model User {
- id String @id @default(cuid())
- name String?
- email String? @unique
- emailVerified DateTime?
- image String?
- accounts Account[]
- sessions Session[]
- typebots Typebot[]
- folders DashboardFolder[]
- plan Plan @default(FREE)
- stripeId String? @unique
- credentials Credentials[]
- customDomains CustomDomain[]
- apiToken String?
+ id String @id @default(cuid())
+ name String?
+ email String? @unique
+ emailVerified DateTime?
+ image String?
+ accounts Account[]
+ sessions Session[]
+ typebots Typebot[]
+ folders DashboardFolder[]
+ plan Plan @default(FREE)
+ stripeId String? @unique
+ credentials Credentials[]
+ customDomains CustomDomain[]
+ apiToken String?
+ CollaboratorsOnTypebots CollaboratorsOnTypebots[]
}
model CustomDomain {
@@ -103,28 +104,54 @@ model DashboardFolder {
}
model Typebot {
- id String @id @default(cuid())
- createdAt DateTime @default(now())
- updatedAt DateTime @default(now()) @updatedAt
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
name String
ownerId String
- owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
publishedTypebotId String?
publishedTypebot PublicTypebot?
results Result[]
folderId String?
- folder DashboardFolder? @relation(fields: [folderId], references: [id])
+ folder DashboardFolder? @relation(fields: [folderId], references: [id])
blocks Json[]
variables Json[]
edges Json[]
theme Json
settings Json
- publicId String? @unique
- customDomain String? @unique
+ publicId String? @unique
+ customDomain String? @unique
+ collaborators CollaboratorsOnTypebots[]
+ invitations Invitation[]
@@unique([id, ownerId])
}
+model Invitation {
+ email String
+ typebotId String
+ typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
+ type CollaborationType
+
+ @@unique([email, typebotId])
+}
+
+model CollaboratorsOnTypebots {
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ typebotId String
+ typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
+ type CollaborationType
+
+ @@unique([userId, typebotId])
+}
+
+enum CollaborationType {
+ READ
+ WRITE
+}
+
model PublicTypebot {
id String @id @default(cuid())
createdAt DateTime @default(now())
diff --git a/packages/utils/src/apiUtils.ts b/packages/utils/src/apiUtils.ts
index 4b727a1cc..4af218383 100644
--- a/packages/utils/src/apiUtils.ts
+++ b/packages/utils/src/apiUtils.ts
@@ -12,6 +12,18 @@ import { byId, isDefined } from '.'
export const methodNotAllowed = (res: NextApiResponse) =>
res.status(405).json({ message: 'Method Not Allowed' })
+export const notAuthenticated = (res: NextApiResponse) =>
+ res.status(401).json({ message: 'Not authenticated' })
+
+export const notFound = (res: NextApiResponse) =>
+ res.status(404).json({ message: 'Not found' })
+
+export const badRequest = (res: NextApiResponse) =>
+ res.status(400).json({ message: 'Bad Request' })
+
+export const forbidden = (res: NextApiResponse) =>
+ res.status(403).json({ message: 'Bad Request' })
+
export const initMiddleware =
(
handler: (
diff --git a/yarn.lock b/yarn.lock
index c6014e3f9..41c0a90fa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6353,6 +6353,11 @@ csstype@^3.0.2, csstype@^3.0.6, csstype@^3.0.9:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.10.tgz#2ad3a7bed70f35b965707c092e5f30b327c290e5"
integrity sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==
+cuid@^2.1.8:
+ version "2.1.8"
+ resolved "https://registry.yarnpkg.com/cuid/-/cuid-2.1.8.tgz#cbb88f954171e0d5747606c0139fb65c5101eac0"
+ integrity sha512-xiEMER6E7TlTPnDxrM4eRiC6TRgjNX9xzEZ5U/Se2YJKr7Mq4pJn/2XEHjl3STcSh96GmkHPcBXLES8M29wyyg==
+
cypress@*:
version "9.5.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.5.0.tgz#704a79f0d3d4e775f433334eb8f5ae065e3bea31"