From b9dafa611e073e142fac7409c8098c20e3cef55b Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Thu, 24 Feb 2022 11:13:19 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Add=20collaboration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../emails/invitationToCollaborate.mjml | 36 ++ .../assets/emails/invitationToCollaborate.ts | 506 ++++++++++++++++++ .../typebot2-paid-user-anouncement.mjml | 50 ++ apps/builder/assets/icons.tsx | 9 + .../components/account/SubscriptionTag.tsx | 2 +- .../components/dashboard/FolderContent.tsx | 17 + .../FolderContent/SharedTypebotsButton.tsx | 40 ++ .../dashboard/FolderContent/TypebotButton.tsx | 87 +-- .../share/customDomain/CustomDomainModal.tsx | 2 +- .../customDomain/CustomDomainsDropdown.tsx | 2 +- .../components/shared/CredentialsDropdown.tsx | 2 +- .../SendEmailSettings/SmtpConfigModal.tsx | 2 +- .../CollaborationList.tsx | 248 +++++++++ .../CollaborationMenuButton.tsx | 30 ++ .../CollaboratorButton.tsx | 101 ++++ .../CollaborationMenuButton/index.tsx | 1 + .../shared/TypebotHeader/TypebotHeader.tsx | 2 + .../TypebotContext/TypebotContext.tsx | 34 +- .../contexts/TypebotContext/actions/steps.ts | 2 +- apps/builder/contexts/TypebotDndContext.tsx | 13 +- apps/builder/contexts/UserContext.tsx | 12 +- .../layouts/dashboard/TemplatesContent.tsx | 2 +- apps/builder/layouts/editor/Board.tsx | 4 +- .../layouts/results/ResultsContent.tsx | 2 +- .../layouts/results/SubmissionContent.tsx | 2 +- apps/builder/package.json | 5 +- apps/builder/pages/_app.tsx | 2 +- apps/builder/pages/api/auth/[...nextauth].ts | 26 +- apps/builder/pages/api/auth/adapter.ts | 79 +++ apps/builder/pages/api/typebots.ts | 2 +- .../builder/pages/api/typebots/[typebotId].ts | 62 ++- .../collaborators copy/[userId].ts | 34 ++ .../api/typebots/[typebotId]/collaborators.ts | 23 + .../[typebotId]/collaborators/[userId].ts | 34 ++ .../api/typebots/[typebotId]/invitations.ts | 58 ++ .../[typebotId]/invitations/[email].ts | 34 ++ .../pages/api/typebots/[typebotId]/results.ts | 2 +- .../pages/api/users/[id]/sharedTypebots.ts | 31 ++ apps/builder/pages/typebots.tsx | 15 +- apps/builder/pages/typebots/shared.tsx | 47 ++ apps/builder/playwright.config.ts | 7 + apps/builder/playwright/freeUser.json | 6 +- apps/builder/playwright/proUser.json | 6 +- apps/builder/playwright/services/database.ts | 9 +- .../playwright/tests/collaboration.spec.ts | 71 +++ apps/builder/services/api/emails.ts | 27 + apps/builder/services/api/utils.ts | 10 + apps/builder/services/folders.ts | 2 +- .../services/typebots/collaborators.ts | 48 ++ apps/builder/services/typebots/index.ts | 2 + apps/builder/services/typebots/invitations.ts | 51 ++ .../services/{ => typebots}/results.ts | 2 +- .../services/{ => typebots}/typebots.ts | 9 +- .../services/{ => user}/credentials.ts | 2 +- .../services/{ => user}/customDomains.ts | 2 +- apps/builder/services/user/index.ts | 3 + apps/builder/services/user/sharedTypebots.ts | 42 ++ apps/builder/services/{ => user}/user.ts | 0 package.json | 2 +- .../migration.sql | 31 ++ packages/db/prisma/schema.prisma | 69 ++- packages/utils/src/apiUtils.ts | 12 + yarn.lock | 5 + 63 files changed, 1932 insertions(+), 148 deletions(-) create mode 100644 apps/builder/assets/emails/invitationToCollaborate.mjml create mode 100644 apps/builder/assets/emails/invitationToCollaborate.ts create mode 100644 apps/builder/assets/emails/typebot2-paid-user-anouncement.mjml create mode 100644 apps/builder/components/dashboard/FolderContent/SharedTypebotsButton.tsx create mode 100644 apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx create mode 100644 apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationMenuButton.tsx create mode 100644 apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaboratorButton.tsx create mode 100644 apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/index.tsx create mode 100644 apps/builder/pages/api/auth/adapter.ts create mode 100644 apps/builder/pages/api/typebots/[typebotId]/collaborators copy/[userId].ts create mode 100644 apps/builder/pages/api/typebots/[typebotId]/collaborators.ts create mode 100644 apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts create mode 100644 apps/builder/pages/api/typebots/[typebotId]/invitations.ts create mode 100644 apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts create mode 100644 apps/builder/pages/api/users/[id]/sharedTypebots.ts create mode 100644 apps/builder/pages/typebots/shared.tsx create mode 100644 apps/builder/playwright/tests/collaboration.spec.ts create mode 100644 apps/builder/services/api/emails.ts create mode 100644 apps/builder/services/api/utils.ts create mode 100644 apps/builder/services/typebots/collaborators.ts create mode 100644 apps/builder/services/typebots/index.ts create mode 100644 apps/builder/services/typebots/invitations.ts rename apps/builder/services/{ => typebots}/results.ts (98%) rename apps/builder/services/{ => typebots}/typebots.ts (96%) rename apps/builder/services/{ => user}/credentials.ts (96%) rename apps/builder/services/{ => user}/customDomains.ts (96%) create mode 100644 apps/builder/services/user/index.ts create mode 100644 apps/builder/services/user/sharedTypebots.ts rename apps/builder/services/{ => user}/user.ts (100%) create mode 100644 packages/db/prisma/migrations/20220224101209_add_collaboration/migration.sql 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 +) => ` + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ header image +
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+ 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 +
+
+
+
+ +
+
+ +
+
+ +
+ + +` 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. + + + + + + Unsubscribe from all future emails + + + + + + + \ 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 ( + + ) +} 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" + /> + + + + + )} + {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') && ( )} 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"