From 2fe6c7b530475da73f97cf37036e5fdccd334b89 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Fri, 13 May 2022 15:22:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E2=9C=A8=20Team=20workspaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/emails/invitationToCollaborate.ts | 5 +- apps/builder/assets/icons.tsx | 16 + .../components/account/AccountHeader.tsx | 24 -- .../components/account/BillingSection.tsx | 70 ---- .../components/account/PersonalInfoForm.tsx | 137 ------- .../components/account/SubscriptionTag.tsx | 22 - .../components/dashboard/DashboardHeader.tsx | 117 ++++-- .../components/dashboard/FolderContent.tsx | 23 +- .../FolderContent/CreateFolderButton.tsx | 10 +- .../FolderContent/SharedTypebotsButton.tsx | 40 -- .../dashboard/FolderContent/TypebotButton.tsx | 4 +- .../components/dashboard/OnboardingModal.tsx | 7 +- .../WorkspaceSettingsModal/BillingForm.tsx | 53 +++ .../MembersList/AddMemberForm.tsx | 118 ++++++ .../MembersList/MemberItem.tsx | 109 +++++ .../MembersList/MembersList.tsx | 132 ++++++ .../MembersList/index.ts | 1 + .../WorkspaceSettingsModal/MyAccountForm.tsx | 127 ++++++ .../UserSettingsForm.tsx} | 4 +- .../WorkspaceSettingsForm.tsx | 45 ++ .../WorkspaceSettingsModal.tsx | 157 +++++++ .../WorkspaceSettingsModal/index.tsx | 1 + .../components/editor/EditorSettingsModal.tsx | 2 +- .../settings/GeneralSettingsForm.tsx | 8 +- .../builder/components/share/ShareContent.tsx | 7 +- .../share/customDomain/CustomDomainModal.tsx | 6 +- .../customDomain/CustomDomainsDropdown.tsx | 25 +- .../components/shared/CredentialsDropdown.tsx | 10 +- ...Icons.tsx => EditableEmojiOrImageIcon.tsx} | 29 +- .../TypebotIcon.tsx => EmojiOrImageIcon.tsx} | 10 +- .../shared/Graph/Edges/DropOffEdge.tsx | 8 +- .../GoogleSheetsConnectModal.tsx | 5 +- .../SendEmailSettings/SendEmailSettings.tsx | 13 +- .../SendEmailSettings/SmtpConfigModal.tsx | 7 +- .../TypebotLinkSettingsForm.tsx | 11 +- .../TypebotsDropdown.tsx | 8 +- apps/builder/components/shared/Info.tsx | 13 +- .../components/shared/MaintenancePage.tsx | 13 + .../components/shared/SupportBubble.tsx | 19 +- .../CollaborationList.tsx | 117 +++--- .../shared/TypebotHeader/TypebotHeader.tsx | 4 +- .../modals/UpgradeModal/UpgradeModal.tsx | 187 ++++++--- .../templates/CreateNewTypebotButtons.tsx | 10 +- .../TypebotContext/TypebotContext.tsx | 39 +- apps/builder/contexts/UserContext.tsx | 6 + apps/builder/contexts/WorkspaceContext.tsx | 144 +++++++ .../layouts/account/AccountContent.tsx | 37 -- .../layouts/results/ResultsContent.tsx | 8 +- .../layouts/results/SubmissionContent.tsx | 6 +- apps/builder/libs/google-sheets.ts | 4 +- apps/builder/pages/_app.tsx | 47 ++- apps/builder/pages/account.tsx | 13 - apps/builder/pages/api/auth/adapter.ts | 89 +++- apps/builder/pages/api/coupons/redeem.ts | 30 -- .../pages/api/{users/[id] => }/credentials.ts | 31 +- .../[id] => }/credentials/[credentialsId].ts | 13 +- .../api/credentials/google-sheets/callback.ts | 19 +- .../api/{users/[id] => }/customDomains.ts | 28 +- .../[id] => }/customDomains/[domain].ts | 14 +- apps/builder/pages/api/folders.ts | 11 +- apps/builder/pages/api/folders/[id].ts | 18 +- .../google-sheets/spreadsheets/[id]/sheets.ts | 2 +- apps/builder/pages/api/stripe/checkout.ts | 20 +- .../pages/api/stripe/customer-portal.ts | 31 +- apps/builder/pages/api/stripe/webhook.ts | 28 +- apps/builder/pages/api/typebots.ts | 33 +- .../builder/pages/api/typebots/[typebotId].ts | 11 +- .../[typebotId]/collaborators/[userId].ts | 21 +- .../api/typebots/[typebotId]/invitations.ts | 22 +- .../[typebotId]/invitations/[email].ts | 27 +- .../pages/api/typebots/[typebotId]/results.ts | 12 +- .../pages/api/users/[id]/sharedTypebots.ts | 38 -- .../builder/pages/api/webhooks/[webhookId].ts | 7 +- apps/builder/pages/api/workspaces.ts | 35 ++ .../pages/api/workspaces/[workspaceId].ts | 28 ++ .../workspaces/[workspaceId]/invitations.ts | 47 +++ .../[workspaceId]/invitations/[id].ts | 39 ++ .../api/workspaces/[workspaceId]/members.ts | 42 ++ .../workspaces/[workspaceId]/members/[id].ts | 48 +++ apps/builder/pages/typebots.tsx | 20 +- apps/builder/pages/typebots/shared.tsx | 47 --- apps/builder/playwright/services/database.ts | 110 ++++- .../playwright/tests/accountSettings.spec.ts | 28 ++ .../playwright/tests/collaboration.spec.ts | 99 +++-- .../playwright/tests/customDomains.spec.ts | 94 +++-- .../playwright/tests/dashboard.spec.ts | 7 +- apps/builder/playwright/tests/results.spec.ts | 9 +- .../builder/playwright/tests/settings.spec.ts | 3 +- .../playwright/tests/workspaces.spec.ts | 134 ++++++ apps/builder/services/api/dbRules.ts | 31 +- .../services/{user => }/credentials.ts | 20 +- .../services/{user => }/customDomains.ts | 24 +- apps/builder/services/folders.ts | 9 +- apps/builder/services/integrations.ts | 5 +- apps/builder/services/publicTypebot.tsx | 1 + apps/builder/services/stripe.ts | 11 +- .../services/typebots/collaborators.ts | 2 +- apps/builder/services/typebots/invitations.ts | 4 +- apps/builder/services/typebots/typebots.ts | 17 +- apps/builder/services/user/index.ts | 4 +- apps/builder/services/user/sharedTypebots.ts | 47 --- apps/builder/services/user/user.ts | 7 +- apps/builder/services/workspace/index.ts | 3 + apps/builder/services/workspace/invitation.ts | 28 ++ apps/builder/services/workspace/member.ts | 41 ++ apps/builder/services/workspace/workspace.ts | 58 +++ apps/docs/docs/self-hosting/configuration.md | 16 +- .../assets/icons/CheckCircleIcon.tsx | 15 + apps/landing-page/assets/icons/CheckIcon.tsx | 10 +- .../assets/icons/HelpCircleIcon.tsx | 10 + .../PricingPage/PlanComparisonTables.tsx | 384 ++++++++++++++++++ .../PricingPage/PricingCard/index.tsx | 13 +- .../components/common/TableCells.tsx | 4 +- apps/landing-page/pages/about.tsx | 2 +- apps/landing-page/pages/pricing.tsx | 96 +++-- apps/viewer/pages/api/typebots.ts | 2 +- .../blocks/[blockId]/sampleResult.ts | 11 +- .../[blockId]/steps/[stepId]/sampleResult.ts | 11 +- .../steps/[stepId]/subscribeWebhook.ts | 11 +- .../steps/[stepId]/unsubscribeWebhook.ts | 11 +- .../blocks/[blockId]/subscribeWebhook.ts | 11 +- .../blocks/[blockId]/unsubscribeWebhook.ts | 11 +- .../pages/api/typebots/[typebotId]/results.ts | 11 +- .../api/typebots/[typebotId]/webhookBlocks.ts | 7 +- .../api/typebots/[typebotId]/webhookSteps.ts | 7 +- apps/viewer/playwright/services/database.ts | 25 +- packages/db/prisma/schema.prisma | 85 +++- packages/scripts/.env.local.example | 3 +- packages/scripts/index.ts | 7 +- packages/scripts/package.json | 3 +- packages/scripts/workspaceMigration.ts | 85 ++++ 131 files changed, 3269 insertions(+), 1227 deletions(-) delete mode 100644 apps/builder/components/account/AccountHeader.tsx delete mode 100644 apps/builder/components/account/BillingSection.tsx delete mode 100644 apps/builder/components/account/PersonalInfoForm.tsx delete mode 100644 apps/builder/components/account/SubscriptionTag.tsx delete mode 100644 apps/builder/components/dashboard/FolderContent/SharedTypebotsButton.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MemberItem.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/index.ts create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/MyAccountForm.tsx rename apps/builder/components/{account/EditorSection.tsx => dashboard/WorkspaceSettingsModal/UserSettingsForm.tsx} (95%) create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsForm.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx create mode 100644 apps/builder/components/dashboard/WorkspaceSettingsModal/index.tsx rename apps/builder/components/shared/{TypebotHeader/EditableTypebotIcons.tsx => EditableEmojiOrImageIcon.tsx} (63%) rename apps/builder/components/shared/{TypebotHeader/TypebotIcon.tsx => EmojiOrImageIcon.tsx} (73%) create mode 100644 apps/builder/components/shared/MaintenancePage.tsx create mode 100644 apps/builder/contexts/WorkspaceContext.tsx delete mode 100644 apps/builder/layouts/account/AccountContent.tsx delete mode 100644 apps/builder/pages/account.tsx delete mode 100644 apps/builder/pages/api/coupons/redeem.ts rename apps/builder/pages/api/{users/[id] => }/credentials.ts (57%) rename apps/builder/pages/api/{users/[id] => }/credentials/[credentialsId].ts (60%) rename apps/builder/pages/api/{users/[id] => }/customDomains.ts (69%) rename apps/builder/pages/api/{users/[id] => }/customDomains/[domain].ts (73%) delete mode 100644 apps/builder/pages/api/users/[id]/sharedTypebots.ts create mode 100644 apps/builder/pages/api/workspaces.ts create mode 100644 apps/builder/pages/api/workspaces/[workspaceId].ts create mode 100644 apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts create mode 100644 apps/builder/pages/api/workspaces/[workspaceId]/invitations/[id].ts create mode 100644 apps/builder/pages/api/workspaces/[workspaceId]/members.ts create mode 100644 apps/builder/pages/api/workspaces/[workspaceId]/members/[id].ts delete mode 100644 apps/builder/pages/typebots/shared.tsx create mode 100644 apps/builder/playwright/tests/accountSettings.spec.ts create mode 100644 apps/builder/playwright/tests/workspaces.spec.ts rename apps/builder/services/{user => }/credentials.ts (62%) rename apps/builder/services/{user => }/customDomains.ts (57%) delete mode 100644 apps/builder/services/user/sharedTypebots.ts create mode 100644 apps/builder/services/workspace/index.ts create mode 100644 apps/builder/services/workspace/invitation.ts create mode 100644 apps/builder/services/workspace/member.ts create mode 100644 apps/builder/services/workspace/workspace.ts create mode 100644 apps/landing-page/assets/icons/CheckCircleIcon.tsx create mode 100644 apps/landing-page/assets/icons/HelpCircleIcon.tsx create mode 100644 apps/landing-page/components/PricingPage/PlanComparisonTables.tsx create mode 100644 packages/scripts/workspaceMigration.ts diff --git a/apps/builder/assets/emails/invitationToCollaborate.ts b/apps/builder/assets/emails/invitationToCollaborate.ts index 1c668f046ae..4ee39d22bc0 100644 --- a/apps/builder/assets/emails/invitationToCollaborate.ts +++ b/apps/builder/assets/emails/invitationToCollaborate.ts @@ -335,9 +335,8 @@ export const invitationToCollaborate = ( color: #000000; " > - From now on you will see this - typebot in your dashboard under - the "Shared with me" button 👍 + From now on you will have access to this + typebot in their workspace 👍 diff --git a/apps/builder/assets/icons.tsx b/apps/builder/assets/icons.tsx index 75503574499..d5ce6a84917 100644 --- a/apps/builder/assets/icons.tsx +++ b/apps/builder/assets/icons.tsx @@ -435,3 +435,19 @@ export const MouseIcon = (props: IconProps) => ( /> ) + +export const HardDriveIcon = (props: IconProps) => ( + + + + + + +) + +export const CreditCardIcon = (props: IconProps) => ( + + + + +) diff --git a/apps/builder/components/account/AccountHeader.tsx b/apps/builder/components/account/AccountHeader.tsx deleted file mode 100644 index e67f135612d..00000000000 --- a/apps/builder/components/account/AccountHeader.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Flex } from '@chakra-ui/react' -import { TypebotLogo } from 'assets/logos' -import { NextChakraLink } from 'components/nextChakra/NextChakraLink' -import React from 'react' - -export const AccountHeader = () => ( - - - - - - - -) diff --git a/apps/builder/components/account/BillingSection.tsx b/apps/builder/components/account/BillingSection.tsx deleted file mode 100644 index 2d5f984c2af..00000000000 --- a/apps/builder/components/account/BillingSection.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - Stack, - Heading, - HStack, - Button, - Text, - Input, - useToast, -} from '@chakra-ui/react' -import { NextChakraLink } from 'components/nextChakra/NextChakraLink' -import { UpgradeButton } from 'components/shared/buttons/UpgradeButton' -import { useUser } from 'contexts/UserContext' -import { Plan } from 'db' -import { useRouter } from 'next/router' -import React, { useState } from 'react' -import { redeemCoupon } from 'services/coupons' -import { SubscriptionTag } from './SubscriptionTag' - -export const BillingSection = () => { - const { reload } = useRouter() - const [isLoading, setIsLoading] = useState(false) - - const { user } = useUser() - const toast = useToast({ - position: 'top-right', - }) - - const handleCouponCodeRedeem = async (e: React.FormEvent) => { - e.preventDefault() - const target = e.target as typeof e.target & { - coupon: { value: string } - } - setIsLoading(true) - const { data, error } = await redeemCoupon(target.coupon.value) - if (error) toast({ title: error.name, description: error.message }) - else { - toast({ description: data?.message }) - setTimeout(reload, 1000) - } - setIsLoading(false) - } - - return ( - - - Billing - - - - Your subscription - - - {user?.stripeId && ( - - )} - {user?.plan === Plan.FREE && } - {user?.plan === Plan.FREE && ( - - - - - )} - - - ) -} diff --git a/apps/builder/components/account/PersonalInfoForm.tsx b/apps/builder/components/account/PersonalInfoForm.tsx deleted file mode 100644 index a4bcbdcda1a..00000000000 --- a/apps/builder/components/account/PersonalInfoForm.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - Stack, - Heading, - HStack, - Avatar, - Button, - FormControl, - FormLabel, - Input, - Tooltip, - Flex, - Text, - InputRightElement, - InputGroup, -} from '@chakra-ui/react' -import { UploadIcon } from 'assets/icons' -import { UploadButton } from 'components/shared/buttons/UploadButton' -import { useUser } from 'contexts/UserContext' -import React, { ChangeEvent, useState } from 'react' -import { isDefined } from 'utils' - -export const PersonalInfoForm = () => { - const { - user, - updateUser, - saveUser, - hasUnsavedChanges, - isSaving, - isOAuthProvider, - } = useUser() - const [reloadParam, setReloadParam] = useState('') - const [isApiTokenVisible, setIsApiTokenVisible] = useState(false) - - const handleFileUploaded = async (url: string) => { - setReloadParam(Date.now().toString()) - updateUser({ image: url }) - } - - const handleNameChange = (e: ChangeEvent) => { - updateUser({ name: e.target.value }) - } - - const handleEmailChange = (e: ChangeEvent) => { - updateUser({ email: e.target.value }) - } - - const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible) - - return ( - - - Personal info - - - - - - } - onFileUploaded={handleFileUploaded} - > - Change photo - - - .jpg or.png, max 1MB - - - - - - Name - - - {isDefined(user?.email) && ( - - - - Email address - - - - - )} - - API token - - - - - - - - - {hasUnsavedChanges && ( - - - - )} - - - ) -} diff --git a/apps/builder/components/account/SubscriptionTag.tsx b/apps/builder/components/account/SubscriptionTag.tsx deleted file mode 100644 index 58b7d958881..00000000000 --- a/apps/builder/components/account/SubscriptionTag.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Tag } from '@chakra-ui/react' -import { Plan } from 'db' - -export const SubscriptionTag = ({ plan }: { plan?: Plan }) => { - switch (plan) { - case Plan.FREE: { - return Free plan - } - case Plan.LIFETIME: { - return Lifetime plan - } - case Plan.OFFERED: { - return Offered - } - case Plan.PRO: { - return Pro plan - } - default: { - return Free plan - } - } -} diff --git a/apps/builder/components/dashboard/DashboardHeader.tsx b/apps/builder/components/dashboard/DashboardHeader.tsx index bddae052f84..2d135c1cfb9 100644 --- a/apps/builder/components/dashboard/DashboardHeader.tsx +++ b/apps/builder/components/dashboard/DashboardHeader.tsx @@ -7,23 +7,40 @@ import { Text, HStack, Flex, - Avatar, SkeletonCircle, - Skeleton, + Button, + useDisclosure, } from '@chakra-ui/react' import { TypebotLogo } from 'assets/logos' import { NextChakraLink } from 'components/nextChakra/NextChakraLink' -import { LogOutIcon, SettingsIcon } from 'assets/icons' +import { + ChevronLeftIcon, + HardDriveIcon, + LogOutIcon, + PlusIcon, + SettingsIcon, +} from 'assets/icons' import { signOut } from 'next-auth/react' import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' +import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon' +import { WorkspaceSettingsModal } from './WorkspaceSettingsModal' export const DashboardHeader = () => { const { user } = useUser() + const { workspace, workspaces, switchWorkspace, createWorkspace } = + useWorkspace() + const { isOpen, onOpen, onClose } = useDisclosure() + const handleLogOut = () => { + localStorage.removeItem('workspaceId') signOut() } + const handleCreateNewWorkspace = () => + createWorkspace(user?.name ?? undefined) + return ( { > - - - - - {user?.name} - - - - - - - - } - > - My account - - }> - Log out - - - + + {user && workspace && ( + + )} + + + + + + + + {workspace && ( + + {workspace.name} + + )} + + + + + {workspaces + ?.filter((w) => w.id !== workspace?.id) + .map((workspace) => ( + switchWorkspace(workspace.id)} + > + + + {workspace.name} + + + ))} + }> + New workspace + + } + color="orange.500" + > + Log out + + + + ) diff --git a/apps/builder/components/dashboard/FolderContent.tsx b/apps/builder/components/dashboard/FolderContent.tsx index 549cb36f242..06456d911f0 100644 --- a/apps/builder/components/dashboard/FolderContent.tsx +++ b/apps/builder/components/dashboard/FolderContent.tsx @@ -19,15 +19,14 @@ import { TypebotInDashboard, useTypebots, } from 'services/typebots' -import { useSharedTypebotsCount } from 'services/user/sharedTypebots' 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' import { OnboardingModal } from './OnboardingModal' +import { useWorkspace } from 'contexts/WorkspaceContext' type Props = { folder: DashboardFolder | null } @@ -35,6 +34,7 @@ const dragDistanceTolerance = 20 export const FolderContent = ({ folder }: Props) => { const { user } = useUser() + const { workspace } = useWorkspace() const [isCreatingFolder, setIsCreatingFolder] = useState(false) const { setDraggedTypebot, @@ -60,6 +60,7 @@ export const FolderContent = ({ folder }: Props) => { isLoading: isFolderLoading, mutate: mutateFolders, } = useFolders({ + workspaceId: workspace?.id, parentId: folder?.id, onError: (error) => { toast({ title: "Couldn't fetch folders", description: error.message }) @@ -71,22 +72,13 @@ export const FolderContent = ({ folder }: Props) => { isLoading: isTypebotLoading, mutate: mutateTypebots, } = useTypebots({ + workspaceId: workspace?.id, folderId: folder?.id, onError: (error) => { toast({ title: "Couldn't fetch typebots", description: error.message }) }, }) - const { totalSharedTypebots } = useSharedTypebotsCount({ - userId: folder === null ? user?.id : undefined, - onError: (error) => { - toast({ - title: "Couldn't fetch shared typebots", - description: error.message, - }) - }, - }) - const moveTypebotToFolder = async (typebotId: string, folderId: string) => { if (!typebots) return const { error } = await patchTypebot(typebotId, { @@ -97,9 +89,9 @@ export const FolderContent = ({ folder }: Props) => { } const handleCreateFolder = async () => { - if (!folders) return + if (!folders || !workspace) return setIsCreatingFolder(true) - const { error, data: newFolder } = await createFolder({ + const { error, data: newFolder } = await createFolder(workspace.id, { parentFolderId: folder?.id ?? null, }) setIsCreatingFolder(false) @@ -164,7 +156,7 @@ export const FolderContent = ({ folder }: Props) => { return ( - {typebots && user && folder === null && ( + {typebots && !isTypebotLoading && user && folder === null && ( )} @@ -185,7 +177,6 @@ export const FolderContent = ({ folder }: Props) => { isLoading={isTypebotLoading} isFirstBot={typebots?.length === 0 && folder === null} /> - {totalSharedTypebots > 0 && } {isFolderLoading && } {folders && folders.map((folder) => ( diff --git a/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx b/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx index 75b8b475d26..a3ead02c48b 100644 --- a/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx +++ b/apps/builder/components/dashboard/FolderContent/CreateFolderButton.tsx @@ -2,18 +2,18 @@ import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react' import { FolderPlusIcon } from 'assets/icons' import { UpgradeModal } from 'components/shared/modals/UpgradeModal' import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal' -import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import React from 'react' -import { isFreePlan } from 'services/user' +import { isFreePlan } from 'services/workspace' type Props = { isLoading: boolean; onClick: () => void } export const CreateFolderButton = ({ isLoading, onClick }: Props) => { - const { user } = useUser() + const { workspace } = useWorkspace() const { isOpen, onOpen, onClose } = useDisclosure() const handleClick = () => { - if (isFreePlan(user)) return onOpen() + if (isFreePlan(workspace)) return onOpen() onClick() } return ( @@ -24,7 +24,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => { > Create a folder - {isFreePlan(user) && Pro} + {isFreePlan(workspace) && Pro} { - 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 7d533edaef7..a562a145fd2 100644 --- a/apps/builder/components/dashboard/FolderContent/TypebotButton.tsx +++ b/apps/builder/components/dashboard/FolderContent/TypebotButton.tsx @@ -20,7 +20,7 @@ import { deleteTypebot, importTypebot, getTypebot } from 'services/typebots' import { Typebot } from 'models' import { useTypebotDnd } from 'contexts/TypebotDndContext' import { useDebounce } from 'use-debounce' -import { TypebotIcon } from 'components/shared/TypebotHeader/TypebotIcon' +import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon' import { useUser } from 'contexts/UserContext' import { Plan } from 'db' @@ -157,7 +157,7 @@ export const TypebotButton = ({ alignItems="center" fontSize={'4xl'} > - {} + {} {typebot.name} diff --git a/apps/builder/components/dashboard/OnboardingModal.tsx b/apps/builder/components/dashboard/OnboardingModal.tsx index 7a7820bd4d1..de7003d07b0 100644 --- a/apps/builder/components/dashboard/OnboardingModal.tsx +++ b/apps/builder/components/dashboard/OnboardingModal.tsx @@ -24,6 +24,7 @@ export const OnboardingModal = ({ totalTypebots }: Props) => { const confettiCanvaContainer = useRef(null) const confettiCanon = useRef() const [chosenCategories, setChosenCategories] = useState([]) + const [openedOnce, setOpenedOnce] = useState(false) const toast = useToast({ position: 'top-right', @@ -37,12 +38,16 @@ export const OnboardingModal = ({ totalTypebots }: Props) => { }, []) useEffect(() => { + if (openedOnce) return const isNewUser = user && new Date(user?.createdAt as unknown as string).toDateString() === new Date().toDateString() && totalTypebots === 0 - if (isNewUser) onOpen() + if (isNewUser) { + onOpen() + setOpenedOnce(true) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]) diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx new file mode 100644 index 00000000000..4b58cd1b7ab --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/BillingForm.tsx @@ -0,0 +1,53 @@ +import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react' +import { ExternalLinkIcon } from 'assets/icons' +import { NextChakraLink } from 'components/nextChakra/NextChakraLink' +import { useWorkspace } from 'contexts/WorkspaceContext' +import { Plan } from 'db' +import React from 'react' + +export const BillingForm = () => { + const { workspace } = useWorkspace() + + return ( + + + Workspace subscription: + + + {workspace?.stripeId && ( + <> + + To manage your subscription and download invoices, head over to your + Stripe portal: + + + + + )} + + ) +} + +const PlanTag = ({ plan }: { plan?: Plan }) => { + switch (plan) { + case Plan.TEAM: { + return Team + } + case Plan.LIFETIME: + case Plan.OFFERED: + case Plan.PRO: { + return Personal Pro + } + default: { + return Free + } + } +} diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx new file mode 100644 index 00000000000..02af2e5ec45 --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/AddMemberForm.tsx @@ -0,0 +1,118 @@ +import { + HStack, + Input, + Button, + Menu, + MenuButton, + MenuList, + Stack, + MenuItem, +} from '@chakra-ui/react' +import { ChevronLeftIcon } from 'assets/icons' +import { WorkspaceInvitation, WorkspaceRole } from 'db' +import { FormEvent, useState } from 'react' +import { Member, sendInvitation } from 'services/workspace' + +type Props = { + workspaceId: string + onNewMember: (member: Member) => void + onNewInvitation: (invitation: WorkspaceInvitation) => void + isLoading: boolean + isLocked: boolean +} +export const AddMemberForm = ({ + workspaceId, + onNewMember, + onNewInvitation, + isLoading, + isLocked, +}: Props) => { + const [invitationEmail, setInvitationEmail] = useState('') + const [invitationRole, setInvitationRole] = useState( + WorkspaceRole.MEMBER + ) + + const [isSendingInvitation, setIsSendingInvitation] = useState(false) + + const handleInvitationSubmit = async (e: FormEvent) => { + e.preventDefault() + setIsSendingInvitation(true) + const { data } = await sendInvitation({ + email: invitationEmail, + type: invitationRole, + workspaceId, + }) + if (data?.member) onNewMember(data.member) + if (data?.invitation) onNewInvitation(data.invitation) + setInvitationEmail('') + setIsSendingInvitation(false) + } + + return ( + + setInvitationEmail(e.target.value)} + rounded="md" + isDisabled={isLocked} + /> + + {!isLocked && ( + + )} + + + ) +} + +const WorkspaceRoleMenuButton = ({ + role, + onChange, +}: { + role: WorkspaceRole + onChange: (role: WorkspaceRole) => void +}) => { + return ( + + } + > + {convertWorkspaceRoleToReadable(role)} + + + + onChange(WorkspaceRole.ADMIN)}> + {convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)} + + onChange(WorkspaceRole.MEMBER)}> + {convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)} + + + + + ) +} + +export const convertWorkspaceRoleToReadable = (role: WorkspaceRole) => { + switch (role) { + case WorkspaceRole.ADMIN: + return 'Admin' + case WorkspaceRole.MEMBER: + return 'Member' + } +} diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MemberItem.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MemberItem.tsx new file mode 100644 index 00000000000..314140d5f30 --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MemberItem.tsx @@ -0,0 +1,109 @@ +import { + Avatar, + HStack, + Menu, + MenuButton, + MenuItem, + MenuList, + Stack, + Tag, + Text, +} from '@chakra-ui/react' +import { WorkspaceRole } from 'db' +import React from 'react' +import { convertWorkspaceRoleToReadable } from './AddMemberForm' + +type Props = { + image?: string + name?: string + email: string + role: WorkspaceRole + isGuest?: boolean + isMe?: boolean + canEdit: boolean + onDeleteClick: () => void + onSelectNewRole: (role: WorkspaceRole) => void +} + +export const MemberItem = ({ + email, + name, + image, + role, + isGuest = false, + isMe = false, + canEdit, + onDeleteClick, + onSelectNewRole, +}: Props) => { + const handleAdminClick = () => onSelectNewRole(WorkspaceRole.ADMIN) + const handleMemberClick = () => onSelectNewRole(WorkspaceRole.MEMBER) + return ( + + + + + {!isMe && canEdit && ( + + + {convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)} + + + {convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)} + + + Remove + + + )} + + ) +} + +export const MemberIdentityContent = ({ + 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/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx new file mode 100644 index 00000000000..459f8b72c4c --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/MembersList.tsx @@ -0,0 +1,132 @@ +import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react' +import { UnlockPlanInfo } from 'components/shared/Info' +import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' +import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db' +import React from 'react' +import { + deleteInvitation, + deleteMember, + Member, + updateInvitation, + updateMember, + useMembers, +} from 'services/workspace' +import { AddMemberForm } from './AddMemberForm' +import { MemberItem } from './MemberItem' + +export const MembersList = () => { + const { user } = useUser() + const { workspace, canEdit } = useWorkspace() + const { members, invitations, isLoading, mutate } = useMembers({ + workspaceId: workspace?.id, + }) + + const handleDeleteMemberClick = (memberId: string) => async () => { + if (!workspace || !members || !invitations) return + await deleteMember(workspace.id, memberId) + mutate({ + members: members.filter((m) => m.userId !== memberId), + invitations, + }) + } + + const handleSelectNewRole = + (memberId: string) => async (role: WorkspaceRole) => { + if (!workspace || !members || !invitations) return + await updateMember(workspace.id, { userId: memberId, role }) + mutate({ + members: members.map((m) => + m.userId === memberId ? { ...m, role } : m + ), + invitations, + }) + } + + const handleDeleteInvitationClick = (id: string) => async () => { + if (!workspace || !members || !invitations) return + await deleteInvitation({ workspaceId: workspace.id, id }) + mutate({ + invitations: invitations.filter((i) => i.id !== id), + members, + }) + } + + const handleSelectNewInvitationRole = + (id: string) => async (type: WorkspaceRole) => { + if (!workspace || !members || !invitations) return + await updateInvitation({ workspaceId: workspace.id, id, type }) + mutate({ + invitations: invitations.map((i) => (i.id === id ? { ...i, type } : i)), + members, + }) + } + + const handleNewInvitation = (invitation: WorkspaceInvitation) => { + if (!members || !invitations) return + mutate({ + members, + invitations: [...invitations, invitation], + }) + } + + const handleNewMember = (member: Member) => { + if (!members || !invitations) return + mutate({ + members: [...members, member], + invitations, + }) + } + + return ( + + {workspace?.plan !== Plan.TEAM && ( + + )} + {workspace?.id && canEdit && ( + + )} + {members?.map((member) => ( + + ))} + {invitations?.map((invitation) => ( + + ))} + {isLoading && ( + + + + + )} + + ) +} diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/index.ts b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/index.ts new file mode 100644 index 00000000000..7ec8f0d5cab --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MembersList/index.ts @@ -0,0 +1 @@ +export { MembersList } from './MembersList' diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/MyAccountForm.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/MyAccountForm.tsx new file mode 100644 index 00000000000..6cf4a159e29 --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/MyAccountForm.tsx @@ -0,0 +1,127 @@ +import { + Stack, + HStack, + Avatar, + Button, + FormControl, + FormLabel, + Input, + Tooltip, + Flex, + Text, + InputRightElement, + InputGroup, +} from '@chakra-ui/react' +import { UploadIcon } from 'assets/icons' +import { UploadButton } from 'components/shared/buttons/UploadButton' +import { useUser } from 'contexts/UserContext' +import React, { ChangeEvent, useState } from 'react' +import { isDefined } from 'utils' + +export const MyAccountForm = () => { + const { + user, + updateUser, + saveUser, + hasUnsavedChanges, + isSaving, + isOAuthProvider, + } = useUser() + const [reloadParam, setReloadParam] = useState('') + const [isApiTokenVisible, setIsApiTokenVisible] = useState(false) + + const handleFileUploaded = async (url: string) => { + setReloadParam(Date.now().toString()) + updateUser({ image: url }) + } + + const handleNameChange = (e: ChangeEvent) => { + updateUser({ name: e.target.value }) + } + + const handleEmailChange = (e: ChangeEvent) => { + updateUser({ email: e.target.value }) + } + + const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible) + + return ( + + + + + } + onFileUploaded={handleFileUploaded} + > + Change photo + + + .jpg or.png, max 1MB + + + + + + Name + + + {isDefined(user?.email) && ( + + + + Email address + + + + + )} + + API token + + + + + + + + + {hasUnsavedChanges && ( + + + + )} + + ) +} diff --git a/apps/builder/components/account/EditorSection.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/UserSettingsForm.tsx similarity index 95% rename from apps/builder/components/account/EditorSection.tsx rename to apps/builder/components/dashboard/WorkspaceSettingsModal/UserSettingsForm.tsx index 451a40be911..60020aa43cc 100644 --- a/apps/builder/components/account/EditorSection.tsx +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/UserSettingsForm.tsx @@ -12,8 +12,6 @@ import { useUser } from 'contexts/UserContext' import { GraphNavigation } from 'db' import React, { useEffect, useState } from 'react' -export const EditorSection = () => - export const EditorSettings = () => { const { user, saveUser } = useUser() const [value, setValue] = useState( @@ -44,7 +42,7 @@ export const EditorSettings = () => { return ( - Navigation + Editor Navigation {options.map((option) => ( diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsForm.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsForm.tsx new file mode 100644 index 00000000000..480407580b5 --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsForm.tsx @@ -0,0 +1,45 @@ +import { Stack, FormControl, FormLabel, Flex } from '@chakra-ui/react' +import { EditableEmojiOrImageIcon } from 'components/shared/EditableEmojiOrImageIcon' +import { Input } from 'components/shared/Textbox' +import { useWorkspace } from 'contexts/WorkspaceContext' +import React from 'react' + +export const WorkspaceSettingsForm = () => { + const { workspace, updateWorkspace } = useWorkspace() + + const handleNameChange = (name: string) => { + if (!workspace?.id) return + updateWorkspace(workspace?.id, { name }) + } + + const handleChangeIcon = (icon: string) => { + if (!workspace?.id) return + updateWorkspace(workspace?.id, { icon }) + } + + return ( + + + Icon + + + + + + Name + {workspace && ( + + )} + + + ) +} diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx new file mode 100644 index 00000000000..54158680e8a --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/WorkspaceSettingsModal.tsx @@ -0,0 +1,157 @@ +import { + Modal, + ModalOverlay, + ModalContent, + Stack, + Text, + Button, + Avatar, + Flex, +} from '@chakra-ui/react' +import { + CreditCardIcon, + HardDriveIcon, + SettingsIcon, + UsersIcon, +} from 'assets/icons' +import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon' +import { useWorkspace } from 'contexts/WorkspaceContext' +import { User, Workspace } from 'db' +import { useState } from 'react' +import { BillingForm } from './BillingForm' +import { MembersList } from './MembersList' +import { MyAccountForm } from './MyAccountForm' +import { EditorSettings } from './UserSettingsForm' +import { WorkspaceSettingsForm } from './WorkspaceSettingsForm' + +type Props = { + isOpen: boolean + user: User + workspace: Workspace + onClose: () => void +} + +type SettingsTab = + | 'my-account' + | 'user-settings' + | 'workspace-settings' + | 'members' + | 'billing' + +export const WorkspaceSettingsModal = ({ + isOpen, + user, + workspace, + onClose, +}: Props) => { + const { canEdit } = useWorkspace() + const [selectedTab, setSelectedTab] = useState('my-account') + + return ( + + + + + + + {user.email} + + + + + + + Workspace + + {canEdit && ( + + )} + + {canEdit && ( + + )} + + + + + + + + ) +} + +const SettingsContent = ({ tab }: { tab: SettingsTab }) => { + switch (tab) { + case 'my-account': + return + case 'user-settings': + return + case 'workspace-settings': + return + case 'members': + return + case 'billing': + return + default: + return null + } +} diff --git a/apps/builder/components/dashboard/WorkspaceSettingsModal/index.tsx b/apps/builder/components/dashboard/WorkspaceSettingsModal/index.tsx new file mode 100644 index 00000000000..4f909e886af --- /dev/null +++ b/apps/builder/components/dashboard/WorkspaceSettingsModal/index.tsx @@ -0,0 +1 @@ +export { WorkspaceSettingsModal } from './WorkspaceSettingsModal' diff --git a/apps/builder/components/editor/EditorSettingsModal.tsx b/apps/builder/components/editor/EditorSettingsModal.tsx index 56d3ce44648..014ad07d835 100644 --- a/apps/builder/components/editor/EditorSettingsModal.tsx +++ b/apps/builder/components/editor/EditorSettingsModal.tsx @@ -5,7 +5,7 @@ import { ModalContent, ModalOverlay, } from '@chakra-ui/react' -import { EditorSettings } from 'components/account/EditorSection' +import { EditorSettings } from 'components/dashboard/WorkspaceSettingsModal/UserSettingsForm' import React from 'react' type Props = { diff --git a/apps/builder/components/settings/GeneralSettingsForm.tsx b/apps/builder/components/settings/GeneralSettingsForm.tsx index 645fa2b422e..46db50d3aa0 100644 --- a/apps/builder/components/settings/GeneralSettingsForm.tsx +++ b/apps/builder/components/settings/GeneralSettingsForm.tsx @@ -8,10 +8,10 @@ import { } from '@chakra-ui/react' import { UpgradeModal } from 'components/shared/modals/UpgradeModal' import { SwitchWithLabel } from 'components/shared/SwitchWithLabel' -import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import { GeneralSettings } from 'models' import React from 'react' -import { isFreePlan } from 'services/user' +import { isFreePlan } from 'services/workspace' type Props = { generalSettings: GeneralSettings @@ -23,8 +23,8 @@ export const GeneralSettingsForm = ({ onGeneralSettingsChange, }: Props) => { const { isOpen, onOpen, onClose } = useDisclosure() - const { user } = useUser() - const isUserFreePlan = isFreePlan(user) + const { workspace } = useWorkspace() + const isUserFreePlan = isFreePlan(workspace) const handleSwitchChange = () => { if (generalSettings?.isBrandingEnabled && isUserFreePlan) return onGeneralSettingsChange({ diff --git a/apps/builder/components/share/ShareContent.tsx b/apps/builder/components/share/ShareContent.tsx index 36a61eba544..52f981b62b7 100644 --- a/apps/builder/components/share/ShareContent.tsx +++ b/apps/builder/components/share/ShareContent.tsx @@ -13,16 +13,17 @@ import { TrashIcon } from 'assets/icons' import { UpgradeButton } from 'components/shared/buttons/UpgradeButton' import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import React from 'react' import { parseDefaultPublicId } from 'services/typebots' -import { isFreePlan } from 'services/user' +import { isFreePlan } from 'services/workspace' import { isDefined, isNotDefined } from 'utils' import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown' import { EditableUrl } from './EditableUrl' import { integrationsList } from './integrations/EmbedButton' export const ShareContent = () => { - const { user } = useUser() + const { workspace } = useWorkspace() const { typebot, updateOnBothTypebots } = useTypebot() const toast = useToast({ position: 'top-right', @@ -83,7 +84,7 @@ export const ShareContent = () => { /> )} - {isFreePlan(user) ? ( + {isFreePlan(workspace) ? ( Add my domain{' '} Pro diff --git a/apps/builder/components/share/customDomain/CustomDomainModal.tsx b/apps/builder/components/share/customDomain/CustomDomainModal.tsx index 5c44841b20e..ed875b28447 100644 --- a/apps/builder/components/share/customDomain/CustomDomainModal.tsx +++ b/apps/builder/components/share/customDomain/CustomDomainModal.tsx @@ -23,7 +23,7 @@ const hostnameRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/ type CustomDomainModalProps = { - userId: string + workspaceId: string isOpen: boolean onClose: () => void domain?: string @@ -31,7 +31,7 @@ type CustomDomainModalProps = { } export const CustomDomainModal = ({ - userId, + workspaceId, isOpen, onClose, onNewDomain, @@ -67,7 +67,7 @@ export const CustomDomainModal = ({ const onAddDomainClick = async () => { if (!hostnameRegex.test(inputValue)) return setIsLoading(true) - const { error } = await createCustomDomain(userId, { + const { error } = await createCustomDomain(workspaceId, { name: inputValue, }) setIsLoading(false) diff --git a/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx index 14cc8de1dd0..d1233a85c1f 100644 --- a/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx +++ b/apps/builder/components/share/customDomain/CustomDomainsDropdown.tsx @@ -16,6 +16,7 @@ import React, { useState } from 'react' import { useUser } from 'contexts/UserContext' import { CustomDomainModal } from './CustomDomainModal' import { deleteCustomDomain, useCustomDomains } from 'services/user' +import { useWorkspace } from 'contexts/WorkspaceContext' type Props = Omit & { currentCustomDomain?: string @@ -29,26 +30,26 @@ export const CustomDomainsDropdown = ({ }: Props) => { const [isDeleting, setIsDeleting] = useState('') const { isOpen, onOpen, onClose } = useDisclosure() - const { user } = useUser() - const { customDomains, mutate } = useCustomDomains({ - userId: user?.id, - onError: (error) => - toast({ title: error.name, description: error.message }), - }) + const { workspace } = useWorkspace() const toast = useToast({ position: 'top-right', status: 'error', }) + const { customDomains, mutate } = useCustomDomains({ + workspaceId: workspace?.id, + onError: (error) => + toast({ title: error.name, description: error.message }), + }) const handleMenuItemClick = (customDomain: string) => () => onCustomDomainSelect(customDomain) const handleDeleteDomainClick = (domainName: string) => async (e: React.MouseEvent) => { - if (!user) return + if (!workspace) return e.stopPropagation() setIsDeleting(domainName) - const { error } = await deleteCustomDomain(user.id, domainName) + const { error } = await deleteCustomDomain(workspace.id, domainName) setIsDeleting('') if (error) return toast({ title: error.name, description: error.message }) mutate({ @@ -59,11 +60,11 @@ export const CustomDomainsDropdown = ({ } const handleNewDomain = (domain: string) => { - if (!user) return + if (!workspace) return mutate({ customDomains: [ ...(customDomains ?? []), - { name: domain, ownerId: user?.id }, + { name: domain, workspaceId: workspace?.id }, ], }) handleMenuItemClick(domain)() @@ -71,9 +72,9 @@ export const CustomDomainsDropdown = ({ return ( - {user?.id && ( + {workspace?.id && ( & { type: CredentialsType @@ -36,13 +36,13 @@ export const CredentialsDropdown = ({ ...props }: Props) => { const router = useRouter() - const { user } = useUser() + const { workspace } = useWorkspace() const toast = useToast({ position: 'top-right', status: 'error', }) const { credentials, mutate } = useCredentials({ - userId: user?.id, + workspaceId: workspace?.id, }) const [isDeleting, setIsDeleting] = useState() @@ -84,9 +84,9 @@ export const CredentialsDropdown = ({ const handleDeleteDomainClick = (credentialsId: string) => async (e: React.MouseEvent) => { e.stopPropagation() - if (!user?.id) return + if (!workspace?.id) return setIsDeleting(credentialsId) - const { error } = await deleteCredentials(user?.id, credentialsId) + const { error } = await deleteCredentials(workspace.id, credentialsId) setIsDeleting(undefined) if (error) return toast({ title: error.name, description: error.message }) onCredentialsSelect(undefined) diff --git a/apps/builder/components/shared/TypebotHeader/EditableTypebotIcons.tsx b/apps/builder/components/shared/EditableEmojiOrImageIcon.tsx similarity index 63% rename from apps/builder/components/shared/TypebotHeader/EditableTypebotIcons.tsx rename to apps/builder/components/shared/EditableEmojiOrImageIcon.tsx index 36a6de1b1f3..74df4afe271 100644 --- a/apps/builder/components/shared/TypebotHeader/EditableTypebotIcons.tsx +++ b/apps/builder/components/shared/EditableEmojiOrImageIcon.tsx @@ -4,22 +4,31 @@ import { chakra, PopoverTrigger, PopoverContent, + Flex, } from '@chakra-ui/react' import React from 'react' -import { ImageUploadContent } from '../ImageUploadContent' -import { TypebotIcon } from './TypebotIcon' +import { EmojiOrImageIcon } from './EmojiOrImageIcon' +import { ImageUploadContent } from './ImageUploadContent' -type Props = { icon?: string | null; onChangeIcon: (icon: string) => void } +type Props = { + icon?: string | null + onChangeIcon: (icon: string) => void + boxSize?: string +} -export const EditableTypebotIcon = ({ icon, onChangeIcon }: Props) => { +export const EditableEmojiOrImageIcon = ({ + icon, + onChangeIcon, + boxSize, +}: Props) => { return ( {({ onClose }) => ( <> - { > - + - + JSX.Element } -export const TypebotIcon = ({ +export const EmojiOrImageIcon = ({ icon, boxSize = '25px', emojiFontSize, + defaultIcon = ToolIcon, }: Props) => { return ( <> @@ -22,7 +24,7 @@ export const TypebotIcon = ({ boxSize={boxSize} objectFit={icon.endsWith('.svg') ? undefined : 'cover'} alt="typebot icon" - rounded="md" + rounded="10%" /> ) : ( @@ -30,7 +32,7 @@ export const TypebotIcon = ({ ) ) : ( - + defaultIcon({ boxSize }) )} ) diff --git a/apps/builder/components/shared/Graph/Edges/DropOffEdge.tsx b/apps/builder/components/shared/Graph/Edges/DropOffEdge.tsx index 0d1fefe1340..ce392fc3b3f 100644 --- a/apps/builder/components/shared/Graph/Edges/DropOffEdge.tsx +++ b/apps/builder/components/shared/Graph/Edges/DropOffEdge.tsx @@ -1,7 +1,7 @@ import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react' import { useGraph } from 'contexts/GraphContext' import { useTypebot } from 'contexts/TypebotContext' -import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import React, { useMemo } from 'react' import { AnswersCount } from 'services/analytics' import { @@ -9,7 +9,7 @@ import { computeSourceCoordinates, computeDropOffPath, } from 'services/graph' -import { isFreePlan } from 'services/user' +import { isFreePlan } from 'services/workspace' import { byId, isDefined } from 'utils' type Props = { @@ -23,11 +23,11 @@ export const DropOffEdge = ({ blockId, onUnlockProPlanClick, }: Props) => { - const { user } = useUser() + const { workspace } = useWorkspace() const { sourceEndpoints, blocksCoordinates, graphPosition } = useGraph() const { publishedTypebot } = useTypebot() - const isUserOnFreePlan = isFreePlan(user) + const isUserOnFreePlan = isFreePlan(workspace) const totalAnswers = useMemo( () => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers, diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/GoogleSheetsSettingsBody/GoogleSheetsConnectModal.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/GoogleSheetsSettingsBody/GoogleSheetsConnectModal.tsx index c6664567bb0..0728a04be2e 100644 --- a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/GoogleSheetsSettingsBody/GoogleSheetsConnectModal.tsx +++ b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/GoogleSheetsSettingsBody/GoogleSheetsConnectModal.tsx @@ -15,6 +15,7 @@ import { import { GoogleLogo } from 'assets/logos' import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { Info } from 'components/shared/Info' +import { useWorkspace } from 'contexts/WorkspaceContext' import React from 'react' import { getGoogleSheetsConsentScreenUrl } from 'services/integrations' @@ -25,6 +26,7 @@ type Props = { } export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => { + const { workspace } = useWorkspace() return ( @@ -54,7 +56,8 @@ export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => { variant="outline" href={getGoogleSheetsConsentScreenUrl( window.location.href, - stepId + stepId, + workspace?.id )} mx="auto" > diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SendEmailSettings.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SendEmailSettings.tsx index 4dca92b0c43..86e280f2409 100644 --- a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SendEmailSettings.tsx +++ b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/SendEmailSettings/SendEmailSettings.tsx @@ -1,10 +1,8 @@ import { Stack, useDisclosure, Text } from '@chakra-ui/react' import { CredentialsDropdown } from 'components/shared/CredentialsDropdown' import { Input, Textarea } from 'components/shared/Textbox' -import { useTypebot } from 'contexts/TypebotContext' import { CredentialsType, SendEmailOptions } from 'models' -import React, { useEffect, useState } from 'react' -import { isDefined } from 'utils' +import React, { useState } from 'react' import { SmtpConfigModal } from './SmtpConfigModal' type Props = { @@ -13,16 +11,9 @@ type Props = { } export const SendEmailSettings = ({ options, onOptionsChange }: Props) => { - const { owner } = useTypebot() const { isOpen, onOpen, onClose } = useDisclosure() const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0) - useEffect(() => { - if (isDefined(options.replyTo) || !owner?.email) return - handleReplyToChange(owner.email) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - const handleCredentialsSelect = (credentialsId?: string) => { setRefreshCredentialsKey(refreshCredentialsKey + 1) onOptionsChange({ @@ -95,7 +86,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => { 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 d802a39443a..b29d8129894 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 @@ -16,6 +16,7 @@ import { createCredentials } from 'services/user' import { testSmtpConfig } from 'services/integrations' import { isNotDefined } from 'utils' import { SmtpConfigForm } from './SmtpConfigForm' +import { useWorkspace } from 'contexts/WorkspaceContext' type Props = { isOpen: boolean @@ -29,6 +30,7 @@ export const SmtpConfigModal = ({ onClose, }: Props) => { const { user } = useUser() + const { workspace } = useWorkspace() const [isCreating, setIsCreating] = useState(false) const toast = useToast({ position: 'top-right', @@ -40,7 +42,7 @@ export const SmtpConfigModal = ({ }) const handleCreateClick = async () => { - if (!user?.email) return + if (!user?.email || !workspace?.id) return setIsCreating(true) const { error: testSmtpError } = await testSmtpConfig( smtpConfig, @@ -53,10 +55,11 @@ export const SmtpConfigModal = ({ description: "We couldn't send the test email with your configuration", }) } - const { data, error } = await createCredentials(user.id, { + const { data, error } = await createCredentials({ data: smtpConfig, name: smtpConfig.from.email as string, type: CredentialsType.SMTP, + workspaceId: workspace.id, }) setIsCreating(false) if (error) return toast({ title: error.name, description: error.message }) diff --git a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/TypebotLinkSettingsForm/TypebotLinkSettingsForm.tsx b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/TypebotLinkSettingsForm/TypebotLinkSettingsForm.tsx index 0770adc5199..9c84836dcda 100644 --- a/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/TypebotLinkSettingsForm/TypebotLinkSettingsForm.tsx +++ b/apps/builder/components/shared/Graph/Nodes/StepNode/SettingsPopoverContent/bodies/TypebotLinkSettingsForm/TypebotLinkSettingsForm.tsx @@ -23,10 +23,13 @@ export const TypebotLinkSettingsForm = ({ return ( - + {typebot && ( + + )} void } -export const TypebotsDropdown = ({ typebotId, onSelectTypebotId }: Props) => { +export const TypebotsDropdown = ({ + typebotId, + onSelectTypebotId, + currentWorkspaceId, +}: Props) => { const { query } = useRouter() const toast = useToast({ position: 'top-right', status: 'error', }) const { typebots, isLoading } = useTypebots({ + workspaceId: currentWorkspaceId, allFolders: true, onError: (e) => toast({ title: e.name, description: e.message }), }) diff --git a/apps/builder/components/shared/Info.tsx b/apps/builder/components/shared/Info.tsx index add51ba8067..69c4e9f175e 100644 --- a/apps/builder/components/shared/Info.tsx +++ b/apps/builder/components/shared/Info.tsx @@ -7,6 +7,7 @@ import { Text, useDisclosure, } from '@chakra-ui/react' +import { Plan } from 'db' import React from 'react' import { UpgradeModal } from './modals/UpgradeModal' import { LimitReached } from './modals/UpgradeModal/UpgradeModal' @@ -22,14 +23,16 @@ export const PublishFirstInfo = (props: AlertProps) => ( You need to publish your typebot first ) -export const UnlockProPlanInfo = ({ +export const UnlockPlanInfo = ({ contentLabel, - buttonLabel, + buttonLabel = 'More info', type, + plan = Plan.PRO, }: { contentLabel: string - buttonLabel: string + buttonLabel?: string type?: LimitReached + plan: Plan }) => { const { isOpen, onOpen, onClose } = useDisclosure() return ( @@ -44,10 +47,10 @@ export const UnlockProPlanInfo = ({ {contentLabel} - - + ) } diff --git a/apps/builder/components/shared/MaintenancePage.tsx b/apps/builder/components/shared/MaintenancePage.tsx new file mode 100644 index 00000000000..d6fe72e384d --- /dev/null +++ b/apps/builder/components/shared/MaintenancePage.tsx @@ -0,0 +1,13 @@ +import { Heading, Text, VStack } from '@chakra-ui/react' +import { TypebotLogo } from 'assets/logos' +import React from 'react' + +export const MaintenancePage = () => ( + + + + The tool is under maintenance for an exciting new feature! 🤩 + + Please come back again in 10 minutes. + +) diff --git a/apps/builder/components/shared/SupportBubble.tsx b/apps/builder/components/shared/SupportBubble.tsx index f0f9444d4f7..9faa281b15d 100644 --- a/apps/builder/components/shared/SupportBubble.tsx +++ b/apps/builder/components/shared/SupportBubble.tsx @@ -1,13 +1,16 @@ import { useTypebot } from 'contexts/TypebotContext' import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import { Plan } from 'db' import React, { useEffect, useState } from 'react' import { isCloudProdInstance } from 'services/utils' +import { planToReadable } from 'services/workspace' import { initBubble } from 'typebot-js' export const SupportBubble = () => { const { typebot } = useTypebot() const { user } = useUser() + const { workspace } = useWorkspace() const [localTypebotId, setLocalTypebotId] = useState(typebot?.id) const [localUserId, setLocalUserId] = useState(user?.id) @@ -33,7 +36,7 @@ export const SupportBubble = () => { Email: user?.email ?? undefined, 'Typebot ID': typebot?.id, 'Avatar URL': user?.image ?? undefined, - Plan: planToReadable(user?.plan), + Plan: planToReadable(workspace?.plan), }, }) } @@ -42,17 +45,3 @@ export const SupportBubble = () => { return <> } - -const planToReadable = (plan?: Plan) => { - if (!plan) return - switch (plan) { - case 'FREE': - return 'Free' - case 'LIFETIME': - return 'Lifetime' - case 'OFFERED': - return 'Offered' - case 'PRO': - return 'Pro' - } -} diff --git a/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx index 6bd997ba473..0ff291ba7f7 100644 --- a/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx +++ b/apps/builder/components/shared/TypebotHeader/CollaborationMenuButton/CollaborationList.tsx @@ -10,11 +10,15 @@ import { MenuList, SkeletonCircle, SkeletonText, + Text, + Tag, + Flex, } from '@chakra-ui/react' import { ChevronLeftIcon } from 'assets/icons' +import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon' import { useTypebot } from 'contexts/TypebotContext' -import { useUser } from 'contexts/UserContext' -import { CollaborationType } from 'db' +import { useWorkspace } from 'contexts/WorkspaceContext' +import { CollaborationType, WorkspaceRole } from 'db' import React, { FormEvent, useState } from 'react' import { deleteCollaborator, @@ -27,21 +31,19 @@ import { deleteInvitation, sendInvitation, } from 'services/typebots/invitations' -import { - CollaboratorIdentityContent, - CollaboratorItem, -} from './CollaboratorButton' +import { CollaboratorItem } from './CollaboratorButton' export const CollaborationList = () => { - const { user } = useUser() - const { typebot, owner } = useTypebot() + const { currentRole, workspace } = useWorkspace() + const { typebot } = useTypebot() const [invitationType, setInvitationType] = useState( CollaborationType.READ ) const [invitationEmail, setInvitationEmail] = useState('') const [isSendingInvitation, setIsSendingInvitation] = useState(false) - const isOwner = user?.email === owner?.email + const hasFullAccess = + (currentRole && currentRole !== WorkspaceRole.GUEST) || false const toast = useToast({ position: 'top-right', @@ -66,12 +68,12 @@ export const CollaborationList = () => { } = useInvitations({ typebotId: typebot?.id, onError: (e) => - toast({ title: "Couldn't fetch collaborators", description: e.message }), + toast({ title: "Couldn't fetch invitations", description: e.message }), }) const handleChangeInvitationCollabType = (email: string) => async (type: CollaborationType) => { - if (!typebot || !isOwner) return + if (!typebot || !hasFullAccess) return const { error } = await updateInvitation(typebot?.id, email, { email, typebotId: typebot.id, @@ -85,7 +87,7 @@ export const CollaborationList = () => { }) } const handleDeleteInvitation = (email: string) => async () => { - if (!typebot || !isOwner) return + if (!typebot || !hasFullAccess) return const { error } = await deleteInvitation(typebot?.id, email) if (error) return toast({ title: error.name, description: error.message }) mutateInvitations({ @@ -95,7 +97,7 @@ export const CollaborationList = () => { const handleChangeCollaborationType = (userId: string) => async (type: CollaborationType) => { - if (!typebot || !isOwner) return + if (!typebot || !hasFullAccess) return const { error } = await updateCollaborator(typebot?.id, userId, { userId, type, @@ -109,7 +111,7 @@ export const CollaborationList = () => { }) } const handleDeleteCollaboration = (userId: string) => async () => { - if (!typebot || !isOwner) return + if (!typebot || !hasFullAccess) return const { error } = await deleteCollaborator(typebot?.id, userId) if (error) return toast({ title: error.name, description: error.message }) mutateCollaborators({ @@ -119,7 +121,7 @@ export const CollaborationList = () => { const handleInvitationSubmit = async (e: FormEvent) => { e.preventDefault() - if (!typebot || !isOwner) return + if (!typebot || !hasFullAccess) return setIsSendingInvitation(true) const { error } = await sendInvitation(typebot.id, { email: invitationEmail, @@ -133,60 +135,57 @@ export const CollaborationList = () => { setInvitationEmail('') } - const hasNobody = - (collaborators ?? []).length > 0 || - ((invitations ?? []).length > 0 && - !isInvitationsLoading && - !isCollaboratorsLoading) - return ( - - {isOwner && ( - - setInvitationEmail(e.target.value)} - rounded="md" - /> + + + setInvitationEmail(e.target.value)} + rounded="md" + isDisabled={!hasFullAccess} + /> + {hasFullAccess && ( - - - )} - {owner && (collaborators ?? []).length > 0 && ( - + )} + + + {workspace && ( + + + + + Everyone at {workspace.name} + + + + {convertCollaborationTypeEnumToReadable( + CollaborationType.FULL_ACCESS + )} + + )} {invitations?.map(({ email, type }) => ( { image={user.image ?? undefined} name={user.name ?? undefined} type={type} - isOwner={isOwner} + isOwner={hasFullAccess} onDeleteClick={handleDeleteCollaboration(userId ?? '')} onChangeCollaborationType={handleChangeCollaborationType(userId)} /> @@ -253,5 +252,7 @@ export const convertCollaborationTypeEnumToReadable = ( return 'Can view' case CollaborationType.WRITE: return 'Can edit' + case CollaborationType.FULL_ACCESS: + return 'Full access' } } diff --git a/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx b/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx index 7c508355f3c..3197bc904e9 100644 --- a/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx +++ b/apps/builder/components/shared/TypebotHeader/TypebotHeader.tsx @@ -15,8 +15,8 @@ import { useRouter } from 'next/router' import React from 'react' import { isNotDefined } from 'utils' import { PublishButton } from '../buttons/PublishButton' +import { EditableEmojiOrImageIcon } from '../EditableEmojiOrImageIcon' import { CollaborationMenuButton } from './CollaborationMenuButton' -import { EditableTypebotIcon } from './EditableTypebotIcons' import { EditableTypebotName } from './EditableTypebotName' export const headerHeight = 56 @@ -123,7 +123,7 @@ export const TypebotHeader = () => { } /> - diff --git a/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx b/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx index 007324b961c..f0435abd325 100644 --- a/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx +++ b/apps/builder/components/shared/modals/UpgradeModal/UpgradeModal.tsx @@ -1,20 +1,28 @@ import { useEffect, useState } from 'react' import { - Alert, - AlertIcon, + Heading, Modal, ModalBody, - ModalCloseButton, + Text, ModalContent, ModalFooter, - ModalHeader, ModalOverlay, Stack, + ListItem, + UnorderedList, + ListIcon, + chakra, + Tooltip, + ListProps, + Button, + HStack, } from '@chakra-ui/react' -import { PricingCard } from './PricingCard' -import { ActionButton } from './ActionButton' import { pay } from 'services/stripe' import { useUser } from 'contexts/UserContext' +import { Plan } from 'db' +import { useWorkspace } from 'contexts/WorkspaceContext' +import { TypebotLogo } from 'assets/logos' +import { CheckIcon } from 'assets/icons' export enum LimitReached { BRAND = 'Remove branding', @@ -26,10 +34,16 @@ type UpgradeModalProps = { type?: LimitReached isOpen: boolean onClose: () => void + plan?: Plan } -export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => { +export const UpgradeModal = ({ + onClose, + isOpen, + plan = Plan.PRO, +}: UpgradeModalProps) => { const { user } = useUser() + const { workspace } = useWorkspace() const [payLoading, setPayLoading] = useState(false) const [currency, setCurrency] = useState<'usd' | 'eur'>('usd') @@ -39,64 +53,133 @@ export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => { ) }, []) - let limitLabel - switch (type) { - case LimitReached.BRAND: { - limitLabel = "You can't hide Typebot brand on the Basic plan" - break - } - case LimitReached.CUSTOM_DOMAIN: { - limitLabel = "You can't add your domain with the Basic plan" - break - } - case LimitReached.FOLDER: { - limitLabel = "You can't create folders with the basic plan" - } - } - const handlePayClick = async () => { - if (!user) return + if (!user || !workspace) return setPayLoading(true) - await pay(user, currency) + await pay({ + user, + currency, + plan: plan === Plan.TEAM ? 'team' : 'pro', + workspaceId: workspace.id, + }) } return ( - + - Upgrade to Pro plan - - - {limitLabel && ( - - - {limitLabel} - + + {plan === Plan.PRO ? ( + + ) : ( + )} - + + + + + + + + + + ) +} + +const PersonalProPlanContent = ({ currency }: { currency: 'eur' | 'usd' }) => { + return ( + + + + Upgrade to Personal Pro{' '} + plan + + For solo creator who want to do even more. + + {currency === 'eur' ? '39€' : '$39'} + / month + + Everything in Personal, plus: + + + ) +} + +const TeamPlanContent = ({ currency }: { currency: 'eur' | 'usd' }) => { + return ( + + + + Upgrade to Team plan + + For teams to build typebots together in one spot. + + {currency === 'eur' ? '99€' : '$99'} + / month + + + - Upgrade now - - } - /> - - - - - + ]} + spacing="0" + /> + } + hasArrow + placement="top" + > + + Everything in Pro + + + , plus: + + + ) } + +const FeatureList = ({ + features, + ...props +}: { features: string[] } & ListProps) => ( + + {features.map((feat) => ( + + + {feat} + + ))} + +) diff --git a/apps/builder/components/templates/CreateNewTypebotButtons.tsx b/apps/builder/components/templates/CreateNewTypebotButtons.tsx index 5345d58c49d..e68fd18299f 100644 --- a/apps/builder/components/templates/CreateNewTypebotButtons.tsx +++ b/apps/builder/components/templates/CreateNewTypebotButtons.tsx @@ -8,6 +8,7 @@ import { } from '@chakra-ui/react' import { ToolIcon, TemplateIcon, DownloadIcon } from 'assets/icons' import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import { Typebot } from 'models' import { useRouter } from 'next/router' import React, { useState } from 'react' @@ -16,6 +17,7 @@ import { ImportTypebotFromFileButton } from './ImportTypebotFromFileButton' import { TemplatesModal } from './TemplatesModal' export const CreateNewTypebotButtons = () => { + const { workspace } = useWorkspace() const { user } = useUser() const router = useRouter() const { isOpen, onOpen, onClose } = useDisclosure() @@ -29,15 +31,16 @@ export const CreateNewTypebotButtons = () => { }) const handleCreateSubmit = async (typebot?: Typebot) => { - if (!user) return + if (!user || !workspace) return setIsLoading(true) const folderId = router.query.folderId?.toString() ?? null const { error, data } = typebot ? await importTypebot( { ...typebot, - ownerId: user.id, folderId, + workspaceId: workspace.id, + ownerId: user.id, theme: { ...typebot.theme, chat: { @@ -46,10 +49,11 @@ export const CreateNewTypebotButtons = () => { }, }, }, - user.plan + workspace.plan ) : await createTypebot({ folderId, + workspaceId: workspace.id, }) if (error) toast({ description: error.message }) if (data) diff --git a/apps/builder/contexts/TypebotContext/TypebotContext.tsx b/apps/builder/contexts/TypebotContext/TypebotContext.tsx index 970a538601c..36b72bcbe10 100644 --- a/apps/builder/contexts/TypebotContext/TypebotContext.tsx +++ b/apps/builder/contexts/TypebotContext/TypebotContext.tsx @@ -40,7 +40,6 @@ import useUndo from 'services/utils/useUndo' import { useDebounce } from 'use-debounce' import { itemsAction, ItemsActions } from './actions/items' import { dequal } from 'dequal' -import { User } from 'db' import { saveWebhook } from 'services/webhook' import { stringify } from 'qs' import cuid from 'cuid' @@ -63,7 +62,6 @@ const typebotContext = createContext< typebot?: Typebot publishedTypebot?: PublicTypebot linkedTypebots?: Typebot[] - owner?: User webhooks: Webhook[] isReadOnly?: boolean isPublished: boolean @@ -108,22 +106,15 @@ export const TypebotContext = ({ status: 'error', }) - const { - typebot, - publishedTypebot, - owner, - webhooks, - isReadOnly, - isLoading, - mutate, - } = useFetchedTypebot({ - typebotId, - onError: (error) => - toast({ - title: 'Error while fetching typebot', - description: error.message, - }), - }) + const { typebot, publishedTypebot, webhooks, isReadOnly, isLoading, mutate } = + useFetchedTypebot({ + typebotId, + onError: (error) => + toast({ + title: 'Error while fetching typebot', + description: error.message, + }), + }) const [ { present: localTypebot }, @@ -150,6 +141,7 @@ export const TypebotContext = ({ ) const { typebots: linkedTypebots } = useLinkedTypebots({ + workspaceId: localTypebot?.workspaceId ?? undefined, typebotId, typebotIds: linkedTypebotIds, onError: (error) => @@ -373,7 +365,6 @@ export const TypebotContext = ({ typebot: localTypebot, publishedTypebot, linkedTypebots, - owner, webhooks: webhooks ?? [], isReadOnly, isSavingLoading, @@ -415,17 +406,17 @@ export const useFetchedTypebot = ({ typebot: Typebot webhooks: Webhook[] publishedTypebot?: PublicTypebot - owner?: User isReadOnly?: boolean }, Error - >(`/api/typebots/${typebotId}`, fetcher, { dedupingInterval: 0 }) + >(`/api/typebots/${typebotId}`, fetcher, { + dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined, + }) if (error) onError(error) return { typebot: data?.typebot, webhooks: data?.webhooks, publishedTypebot: data?.publishedTypebot, - owner: data?.owner, isReadOnly: data?.isReadOnly, isLoading: !error && !data, mutate, @@ -433,15 +424,17 @@ export const useFetchedTypebot = ({ } const useLinkedTypebots = ({ + workspaceId, typebotId, typebotIds, onError, }: { + workspaceId?: string typebotId?: string typebotIds?: string[] onError: (error: Error) => void }) => { - const params = stringify({ typebotIds }, { indices: false }) + const params = stringify({ typebotIds, workspaceId }, { indices: false }) const { data, error, mutate } = useSWR< { typebots: Typebot[] diff --git a/apps/builder/contexts/UserContext.tsx b/apps/builder/contexts/UserContext.tsx index 6980d053d00..dc0c9267e47 100644 --- a/apps/builder/contexts/UserContext.tsx +++ b/apps/builder/contexts/UserContext.tsx @@ -21,6 +21,7 @@ const userContext = createContext<{ isSaving: boolean hasUnsavedChanges: boolean isOAuthProvider: boolean + currentWorkspaceId?: string updateUser: (newUser: Partial) => void saveUser: (newUser?: Partial) => Promise // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -35,6 +36,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => { position: 'top-right', status: 'error', }) + const [currentWorkspaceId, setCurrentWorkspaceId] = useState() const [isSaving, setIsSaving] = useState(false) const isOAuthProvider = useMemo( @@ -49,6 +51,9 @@ export const UserContext = ({ children }: { children: ReactNode }) => { useEffect(() => { if (isDefined(user) || isNotDefined(session)) return + setCurrentWorkspaceId( + localStorage.getItem('currentWorkspaceId') ?? undefined + ) const parsedUser = session.user as User setUser(parsedUser) if (parsedUser?.id) setSentryUser({ id: parsedUser.id }) @@ -96,6 +101,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => { isLoading: status === 'loading', hasUnsavedChanges, isOAuthProvider, + currentWorkspaceId, updateUser, saveUser, }} diff --git a/apps/builder/contexts/WorkspaceContext.tsx b/apps/builder/contexts/WorkspaceContext.tsx new file mode 100644 index 00000000000..d956a5aa446 --- /dev/null +++ b/apps/builder/contexts/WorkspaceContext.tsx @@ -0,0 +1,144 @@ +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from 'react' +import { byId } from 'utils' +import { MemberInWorkspace, Plan, Workspace, WorkspaceRole } from 'db' +import { + createNewWorkspace, + useWorkspaces, + updateWorkspace as patchWorkspace, +} from 'services/workspace/workspace' +import { useUser } from './UserContext' +import { useTypebot } from './TypebotContext' + +export type WorkspaceWithMembers = Workspace & { members: MemberInWorkspace[] } + +const workspaceContext = createContext<{ + workspaces?: WorkspaceWithMembers[] + isLoading: boolean + workspace?: WorkspaceWithMembers + canEdit: boolean + currentRole?: WorkspaceRole + switchWorkspace: (workspaceId: string) => void + createWorkspace: (name?: string) => Promise + updateWorkspace: ( + workspaceId: string, + updates: Partial + ) => Promise + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore +}>({}) + +export const WorkspaceContext = ({ children }: { children: ReactNode }) => { + const { user } = useUser() + const userId = user?.id + const { typebot } = useTypebot() + const { workspaces, isLoading, mutate } = useWorkspaces({ userId }) + const [currentWorkspace, setCurrentWorkspace] = + useState() + + const canEdit = + workspaces + ?.find(byId(currentWorkspace?.id)) + ?.members.find((m) => m.userId === userId)?.role === WorkspaceRole.ADMIN + + const currentRole = currentWorkspace?.members.find( + (m) => m.userId === userId + )?.role + + console.log(workspaces) + useEffect(() => { + if (!workspaces || workspaces.length === 0) return + const lastWorspaceId = localStorage.getItem('workspaceId') + const defaultWorkspace = lastWorspaceId + ? workspaces.find(byId(lastWorspaceId)) + : workspaces.find((w) => + w.members.some( + (m) => m.userId === userId && m.role === WorkspaceRole.ADMIN + ) + ) + setCurrentWorkspace(defaultWorkspace ?? workspaces[0]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaces?.length]) + + useEffect(() => { + if (!currentWorkspace?.id) return + localStorage.setItem('workspaceId', currentWorkspace.id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentWorkspace?.id]) + + useEffect(() => { + if ( + !typebot?.workspaceId || + !currentWorkspace || + typebot.workspaceId === currentWorkspace.id + ) + return + switchWorkspace(typebot.workspaceId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [typebot?.workspaceId]) + + const switchWorkspace = (workspaceId: string) => + setCurrentWorkspace(workspaces?.find(byId(workspaceId))) + + const createWorkspace = async (name?: string) => { + if (!workspaces) return + const { data, error } = await createNewWorkspace({ + name: name ? `${name}'s workspace` : 'My workspace', + plan: Plan.FREE, + }) + if (error || !data) return + const { workspace } = data + const newWorkspace = { + ...workspace, + members: [ + { + role: WorkspaceRole.ADMIN, + userId: userId as string, + workspaceId: workspace.id as string, + }, + ], + } + mutate({ + workspaces: [...workspaces, newWorkspace], + }) + setCurrentWorkspace(newWorkspace) + } + + const updateWorkspace = async ( + workspaceId: string, + updates: Partial + ) => { + const { data } = await patchWorkspace({ id: workspaceId, ...updates }) + if (!data || !currentWorkspace) return + setCurrentWorkspace({ ...currentWorkspace, ...updates }) + mutate({ + workspaces: (workspaces ?? []).map((w) => + w.id === workspaceId ? { ...data.workspace, members: w.members } : w + ), + }) + } + + return ( + + {children} + + ) +} + +export const useWorkspace = () => useContext(workspaceContext) diff --git a/apps/builder/layouts/account/AccountContent.tsx b/apps/builder/layouts/account/AccountContent.tsx deleted file mode 100644 index 9fb6b6f8079..00000000000 --- a/apps/builder/layouts/account/AccountContent.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Flex, Stack, Heading, Divider, Button } from '@chakra-ui/react' -import { ChevronLeftIcon } from 'assets/icons' -import { NextChakraLink } from 'components/nextChakra/NextChakraLink' -import React from 'react' -import { PersonalInfoForm } from 'components/account/PersonalInfoForm' -import { BillingSection } from 'components/account/BillingSection' -import { EditorSection } from 'components/account/EditorSection' - -export const AccountContent = () => { - return ( - - - - - - - - Account Settings - - - - - - - - - - ) -} diff --git a/apps/builder/layouts/results/ResultsContent.tsx b/apps/builder/layouts/results/ResultsContent.tsx index 4ce2d3a6907..b364dcceb39 100644 --- a/apps/builder/layouts/results/ResultsContent.tsx +++ b/apps/builder/layouts/results/ResultsContent.tsx @@ -1,17 +1,17 @@ import { Button, Flex, HStack, Tag, useToast, Text } from '@chakra-ui/react' import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { useTypebot } from 'contexts/TypebotContext/TypebotContext' -import { useUser } from 'contexts/UserContext' +import { useWorkspace } from 'contexts/WorkspaceContext' import { useRouter } from 'next/router' import React, { useMemo } from 'react' import { useStats } from 'services/analytics' -import { isFreePlan } from 'services/user/user' +import { isFreePlan } from 'services/workspace' import { AnalyticsContent } from './AnalyticsContent' import { SubmissionsContent } from './SubmissionContent' export const ResultsContent = () => { const router = useRouter() - const { user } = useUser() + const { workspace } = useWorkspace() const { typebot, publishedTypebot } = useTypebot() const isAnalytics = useMemo( () => router.pathname.endsWith('analytics'), @@ -81,7 +81,7 @@ export const ResultsContent = () => { onDeleteResults={handleDeletedResults} totalResults={stats?.totalStarts ?? 0} totalHiddenResults={ - isFreePlan(user) + isFreePlan(workspace) ? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0) : undefined } diff --git a/apps/builder/layouts/results/SubmissionContent.tsx b/apps/builder/layouts/results/SubmissionContent.tsx index 251bccd05e4..47f485ad42a 100644 --- a/apps/builder/layouts/results/SubmissionContent.tsx +++ b/apps/builder/layouts/results/SubmissionContent.tsx @@ -10,10 +10,11 @@ import { useResults, } from 'services/typebots' import { unparse } from 'papaparse' -import { UnlockProPlanInfo } from 'components/shared/Info' +import { UnlockPlanInfo } from 'components/shared/Info' import { LogsModal } from './LogsModal' import { useTypebot } from 'contexts/TypebotContext' import { isDefined, parseResultHeader } from 'utils' +import { Plan } from 'db' type Props = { typebotId: string @@ -147,9 +148,10 @@ export const SubmissionsContent = ({ return ( {totalHiddenResults && ( - )} {publishedTypebot && ( diff --git a/apps/builder/libs/google-sheets.ts b/apps/builder/libs/google-sheets.ts index b460511352a..76ad414e71c 100644 --- a/apps/builder/libs/google-sheets.ts +++ b/apps/builder/libs/google-sheets.ts @@ -17,9 +17,9 @@ export const getAuthenticatedGoogleClient = async ( { client: OAuth2Client; credentials: CredentialsFromDb } | undefined > => { const credentials = (await prisma.credentials.findFirst({ - where: { id: credentialsId, ownerId: userId }, + where: { id: credentialsId, workspace: { members: { some: { userId } } } }, })) as CredentialsFromDb | undefined - if (!credentials || credentials.ownerId !== userId) return + if (!credentials) return const data = decrypt( credentials.data, credentials.iv diff --git a/apps/builder/pages/_app.tsx b/apps/builder/pages/_app.tsx index 79f05da80d0..b84fd12ad13 100644 --- a/apps/builder/pages/_app.tsx +++ b/apps/builder/pages/_app.tsx @@ -17,6 +17,8 @@ import { KBarProvider } from 'kbar' import { actions } from 'libs/kbar' import { enableMocks } from 'mocks' import { SupportBubble } from 'components/shared/SupportBubble' +import { WorkspaceContext } from 'contexts/WorkspaceContext' +import { MaintenancePage } from 'components/shared/MaintenancePage' if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks() @@ -31,27 +33,30 @@ const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => { }, [pathname]) const typebotId = query.typebotId?.toString() - return ( - - - - - {typebotId ? ( - - - - - ) : ( - <> - - - - )} - - - - - ) + return + // return ( + // + // + // + // + // {typebotId ? ( + // + // + // + // + // + // + // ) : ( + // + // + // + // + // )} + // + // + // + // + // ) } export default App diff --git a/apps/builder/pages/account.tsx b/apps/builder/pages/account.tsx deleted file mode 100644 index ce95ef97247..00000000000 --- a/apps/builder/pages/account.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Stack } from '@chakra-ui/react' -import { AccountHeader } from 'components/account/AccountHeader' -import { Seo } from 'components/Seo' -import { AccountContent } from 'layouts/account/AccountContent' - -const AccountSubscriptionPage = () => ( - - - - - -) -export default AccountSubscriptionPage diff --git a/apps/builder/pages/api/auth/adapter.ts b/apps/builder/pages/api/auth/adapter.ts index 0d680b5a51e..faecb2e6e75 100644 --- a/apps/builder/pages/api/auth/adapter.ts +++ b/apps/builder/pages/api/auth/adapter.ts @@ -1,16 +1,33 @@ // Forked from https://github.com/nextauthjs/adapters/blob/main/packages/prisma/src/index.ts -import { PrismaClient, Prisma, Invitation, Plan } from 'db' +import { + PrismaClient, + Prisma, + Invitation, + Plan, + WorkspaceRole, + WorkspaceInvitation, +} from 'db' import { randomUUID } from 'crypto' import type { Adapter, AdapterUser } from 'next-auth/adapters' import cuid from 'cuid' import { got } from 'got' +type InvitationWithWorkspaceId = Invitation & { + typebot: { + workspaceId: string | null + } +} + 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 }, + include: { typebot: { select: { workspaceId: true } } }, + }) + const workspaceInvitations = await p.workspaceInvitation.findMany({ + where: { email: user.email }, }) const createdUser = await p.user.create({ data: { @@ -18,6 +35,25 @@ export function CustomAdapter(p: PrismaClient): Adapter { id: user.id, apiToken: randomUUID(), plan: process.env.ADMIN_EMAIL === data.email ? Plan.PRO : Plan.FREE, + workspaces: + workspaceInvitations.length > 0 + ? undefined + : { + create: { + role: WorkspaceRole.ADMIN, + workspace: { + create: { + name: data.name + ? `${data.name}'s workspace` + : `My workspace`, + plan: + process.env.ADMIN_EMAIL === data.email + ? Plan.TEAM + : Plan.FREE, + }, + }, + }, + }, }, }) if (process.env.USER_CREATED_WEBHOOK_URL) @@ -29,6 +65,8 @@ export function CustomAdapter(p: PrismaClient): Adapter { }) if (invitations.length > 0) await convertInvitationsToCollaborations(p, user, invitations) + if (workspaceInvitations.length > 0) + await joinWorkspaces(p, user, workspaceInvitations) return createdUser }, getUser: (id) => p.user.findUnique({ where: { id } }), @@ -59,7 +97,7 @@ export function CustomAdapter(p: PrismaClient): Adapter { oauth_token_secret: data.oauth_token_secret as string, oauth_token: data.oauth_token as string, refresh_token_expires_in: data.refresh_token_expires_in as number, - } + }, }) as any }, unlinkAccount: (provider_providerAccountId) => @@ -94,7 +132,7 @@ export function CustomAdapter(p: PrismaClient): Adapter { const convertInvitationsToCollaborations = async ( p: PrismaClient, { id, email }: { id: string; email: string }, - invitations: Invitation[] + invitations: InvitationWithWorkspaceId[] ) => { await p.collaboratorsOnTypebots.createMany({ data: invitations.map((invitation) => ({ @@ -103,9 +141,54 @@ const convertInvitationsToCollaborations = async ( userId: id, })), }) + const workspaceInvitations = invitations.reduce( + (acc, invitation) => + acc.some( + (inv) => inv.typebot.workspaceId === invitation.typebot.workspaceId + ) + ? acc + : [...acc, invitation], + [] + ) + for (const invitation of workspaceInvitations) { + if (!invitation.typebot.workspaceId) continue + await p.memberInWorkspace.upsert({ + where: { + userId_workspaceId: { + userId: id, + workspaceId: invitation.typebot.workspaceId, + }, + }, + create: { + userId: id, + workspaceId: invitation.typebot.workspaceId, + role: WorkspaceRole.GUEST, + }, + update: {}, + }) + } return p.invitation.deleteMany({ where: { email, }, }) } + +const joinWorkspaces = async ( + p: PrismaClient, + { id, email }: { id: string; email: string }, + invitations: WorkspaceInvitation[] +) => { + await p.memberInWorkspace.createMany({ + data: invitations.map((invitation) => ({ + workspaceId: invitation.workspaceId, + role: invitation.type, + userId: id, + })), + }) + return p.workspaceInvitation.deleteMany({ + where: { + email, + }, + }) +} diff --git a/apps/builder/pages/api/coupons/redeem.ts b/apps/builder/pages/api/coupons/redeem.ts deleted file mode 100644 index d8394e6abd4..00000000000 --- a/apps/builder/pages/api/coupons/redeem.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { withSentry } from '@sentry/nextjs' -import { Prisma } from 'db' -import prisma from 'libs/prisma' -import { NextApiRequest, NextApiResponse } from 'next' -import { getAuthenticatedUser } from 'services/api/utils' -import { notAuthenticated } from 'utils' - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === 'POST') { - const user = await getAuthenticatedUser(req) - if (!user) return notAuthenticated(res) - const { code } = - typeof req.body === 'string' ? JSON.parse(req.body) : req.body - const coupon = await prisma.coupon.findFirst({ - where: { code, dateRedeemed: null }, - }) - if (!coupon) return res.status(404).send({ message: 'Coupon not found' }) - await prisma.user.update({ - where: { id: user.id }, - data: coupon.userPropertiesToUpdate as Prisma.UserUncheckedUpdateInput, - }) - await prisma.coupon.update({ - where: { code }, - data: { dateRedeemed: new Date() }, - }) - return res.send({ message: 'Coupon redeemed 🎊' }) - } -} - -export default withSentry(handler) diff --git a/apps/builder/pages/api/users/[id]/credentials.ts b/apps/builder/pages/api/credentials.ts similarity index 57% rename from apps/builder/pages/api/users/[id]/credentials.ts rename to apps/builder/pages/api/credentials.ts index b5537218660..aaedd7a7f9e 100644 --- a/apps/builder/pages/api/users/[id]/credentials.ts +++ b/apps/builder/pages/api/credentials.ts @@ -1,35 +1,48 @@ import { withSentry } from '@sentry/nextjs' -import { Prisma } from 'db' import prisma from 'libs/prisma' import { Credentials } from 'models' import { NextApiRequest, NextApiResponse } from 'next' import { getAuthenticatedUser } from 'services/api/utils' -import { encrypt, methodNotAllowed, notAuthenticated } from 'utils' +import { + badRequest, + encrypt, + forbidden, + methodNotAllowed, + notAuthenticated, +} from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) - const id = req.query.id.toString() - if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) + const workspaceId = req.query.workspaceId as string | undefined + if (!workspaceId) return badRequest(res) if (req.method === 'GET') { const credentials = await prisma.credentials.findMany({ - where: { ownerId: user.id }, - select: { name: true, type: true, ownerId: true, id: true }, + where: { + workspace: { id: workspaceId, members: { some: { userId: user.id } } }, + }, + select: { name: true, type: true, workspaceId: true, id: true }, }) + console.log('Hey there', credentials) return res.send({ credentials }) } if (req.method === 'POST') { const data = ( typeof req.body === 'string' ? JSON.parse(req.body) : req.body - ) as Omit + ) as Credentials const { encryptedData, iv } = encrypt(data.data) + const workspace = await prisma.workspace.findFirst({ + where: { id: workspaceId, members: { some: { userId: user.id } } }, + select: { id: true }, + }) + if (!workspace) return forbidden(res) const credentials = await prisma.credentials.create({ data: { ...data, data: encryptedData, iv, - ownerId: user.id, - } as Prisma.CredentialsUncheckedCreateInput, + workspaceId, + }, }) return res.send({ credentials }) } diff --git a/apps/builder/pages/api/users/[id]/credentials/[credentialsId].ts b/apps/builder/pages/api/credentials/[credentialsId].ts similarity index 60% rename from apps/builder/pages/api/users/[id]/credentials/[credentialsId].ts rename to apps/builder/pages/api/credentials/[credentialsId].ts index 2cb89ac8cb2..42868c5d8ec 100644 --- a/apps/builder/pages/api/users/[id]/credentials/[credentialsId].ts +++ b/apps/builder/pages/api/credentials/[credentialsId].ts @@ -2,17 +2,20 @@ 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' +import { badRequest, methodNotAllowed, notAuthenticated } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) - const id = req.query.id.toString() - if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) + const workspaceId = req.query.workspaceId as string | undefined + if (!workspaceId) return badRequest(res) if (req.method === 'DELETE') { const credentialsId = req.query.credentialsId.toString() - const credentials = await prisma.credentials.delete({ - where: { id: credentialsId }, + const credentials = await prisma.credentials.deleteMany({ + where: { + id: credentialsId, + workspace: { id: workspaceId, members: { some: { userId: user.id } } }, + }, }) return res.send({ credentials }) } diff --git a/apps/builder/pages/api/credentials/google-sheets/callback.ts b/apps/builder/pages/api/credentials/google-sheets/callback.ts index fa1c684b4aa..f6cb753b446 100644 --- a/apps/builder/pages/api/credentials/google-sheets/callback.ts +++ b/apps/builder/pages/api/credentials/google-sheets/callback.ts @@ -4,7 +4,7 @@ import prisma from 'libs/prisma' import { googleSheetsScopes } from './consent-url' import { stringify } from 'querystring' import { CredentialsType } from 'models' -import { encrypt, notAuthenticated } from 'utils' +import { badRequest, encrypt, notAuthenticated } from 'utils' import { oauth2Client } from 'libs/google-sheets' import { withSentry } from '@sentry/nextjs' import { getAuthenticatedUser } from 'services/api/utils' @@ -12,11 +12,12 @@ import { getAuthenticatedUser } from 'services/api/utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) - const { redirectUrl, stepId } = JSON.parse( + const { redirectUrl, stepId, workspaceId } = JSON.parse( Buffer.from(req.query.state.toString(), 'base64').toString() ) if (req.method === 'GET') { const code = req.query.code.toString() + if (!workspaceId) return badRequest(res) if (!code) return res.status(400).send({ message: "Bad request, couldn't get code" }) const { tokens } = await oauth2Client.getToken(code) @@ -41,20 +42,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const credentials = { name: email, type: CredentialsType.GOOGLE_SHEETS, - ownerId: user.id, + workspaceId, data: encryptedData, iv, } as Prisma.CredentialsUncheckedCreateInput - const { id: credentialsId } = await prisma.credentials.upsert({ - create: credentials, - update: credentials, - where: { - name_type_ownerId: { - name: credentials.name, - type: credentials.type, - ownerId: user.id, - }, - }, + const { id: credentialsId } = await prisma.credentials.create({ + data: credentials, }) const queryParams = stringify({ stepId, credentialsId }) res.redirect( diff --git a/apps/builder/pages/api/users/[id]/customDomains.ts b/apps/builder/pages/api/customDomains.ts similarity index 69% rename from apps/builder/pages/api/users/[id]/customDomains.ts rename to apps/builder/pages/api/customDomains.ts index f8ebc4d749d..cdd7d862db1 100644 --- a/apps/builder/pages/api/users/[id]/customDomains.ts +++ b/apps/builder/pages/api/customDomains.ts @@ -1,26 +1,38 @@ import { withSentry } from '@sentry/nextjs' -import { CustomDomain, Prisma } from 'db' +import { CustomDomain } from 'db' import { got, HTTPError } from 'got' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' import { getAuthenticatedUser } from 'services/api/utils' -import { methodNotAllowed, notAuthenticated } from 'utils' +import { + badRequest, + forbidden, + methodNotAllowed, + notAuthenticated, +} from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) - const id = req.query.id.toString() - if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) + const workspaceId = req.query.workspaceId as string | undefined + if (!workspaceId) return badRequest(res) if (req.method === 'GET') { const customDomains = await prisma.customDomain.findMany({ - where: { ownerId: user.id }, + where: { + workspace: { id: workspaceId, members: { some: { userId: user.id } } }, + }, }) return res.send({ customDomains }) } if (req.method === 'POST') { + const workspace = await prisma.workspace.findFirst({ + where: { id: workspaceId, members: { some: { userId: user.id } } }, + select: { id: true }, + }) + if (!workspace) return forbidden(res) const data = ( typeof req.body === 'string' ? JSON.parse(req.body) : req.body - ) as Omit + ) as CustomDomain try { await createDomainOnVercel(data.name) } catch (err) { @@ -31,8 +43,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const customDomains = await prisma.customDomain.create({ data: { ...data, - ownerId: user.id, - } as Prisma.CustomDomainUncheckedCreateInput, + workspaceId, + }, }) return res.send({ customDomains }) } diff --git a/apps/builder/pages/api/users/[id]/customDomains/[domain].ts b/apps/builder/pages/api/customDomains/[domain].ts similarity index 73% rename from apps/builder/pages/api/users/[id]/customDomains/[domain].ts rename to apps/builder/pages/api/customDomains/[domain].ts index a2385f6b9ff..3cbfc676f96 100644 --- a/apps/builder/pages/api/users/[id]/customDomains/[domain].ts +++ b/apps/builder/pages/api/customDomains/[domain].ts @@ -1,15 +1,15 @@ import { withSentry } from '@sentry/nextjs' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' -import { methodNotAllowed, notAuthenticated } from 'utils' +import { badRequest, methodNotAllowed, notAuthenticated } from 'utils' import { got } from 'got' import { getAuthenticatedUser } from 'services/api/utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) - const id = req.query.id.toString() - if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) + const workspaceId = req.query.workspaceId as string | undefined + if (!workspaceId) return badRequest(res) if (req.method === 'DELETE') { const domain = req.query.domain.toString() try { @@ -18,6 +18,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const customDomains = await prisma.customDomain.delete({ where: { name: domain }, }) + console.log( + { + name: domain, + workspace: { id: workspaceId }, + }, + { some: { userId: user.id } } + ) + await deleteDomainOnVercel(domain) return res.send({ customDomains }) } return methodNotAllowed(res) diff --git a/apps/builder/pages/api/folders.ts b/apps/builder/pages/api/folders.ts index ca69b28e20e..56d9444d53f 100644 --- a/apps/builder/pages/api/folders.ts +++ b/apps/builder/pages/api/folders.ts @@ -3,7 +3,7 @@ import { DashboardFolder } from 'db' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' import { getAuthenticatedUser } from 'services/api/utils' -import { methodNotAllowed, notAuthenticated } from 'utils' +import { badRequest, methodNotAllowed, notAuthenticated } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) @@ -12,11 +12,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const parentFolderId = req.query.parentId ? req.query.parentId.toString() : null + if (req.method === 'GET') { + const workspaceId = req.query.workspaceId as string | undefined + if (!workspaceId) return badRequest(res) const folders = await prisma.dashboardFolder.findMany({ where: { - ownerId: user.id, parentFolderId, + workspace: { members: { some: { userId: user.id, workspaceId } } }, }, orderBy: { createdAt: 'desc' }, }) @@ -25,9 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'POST') { const data = ( typeof req.body === 'string' ? JSON.parse(req.body) : req.body - ) as Pick + ) as Pick const folder = await prisma.dashboardFolder.create({ - data: { ...data, ownerId: user.id, name: 'New folder' }, + data: { ...data, name: 'New folder' }, }) return res.send(folder) } diff --git a/apps/builder/pages/api/folders/[id].ts b/apps/builder/pages/api/folders/[id].ts index e8d5b07b566..026c09648c3 100644 --- a/apps/builder/pages/api/folders/[id].ts +++ b/apps/builder/pages/api/folders/[id].ts @@ -11,14 +11,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const id = req.query.id.toString() if (req.method === 'GET') { - const folder = await prisma.dashboardFolder.findUnique({ - where: { id_ownerId: { id, ownerId: user.id } }, + const folder = await prisma.dashboardFolder.findFirst({ + where: { + id, + workspace: { members: { some: { userId: user.id } } }, + }, }) return res.send({ folder }) } if (req.method === 'DELETE') { - const folders = await prisma.dashboardFolder.delete({ - where: { id_ownerId: { id, ownerId: user.id } }, + const folders = await prisma.dashboardFolder.deleteMany({ + where: { id, workspace: { members: { some: { userId: user.id } } } }, }) return res.send({ folders }) } @@ -26,8 +29,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const data = ( typeof req.body === 'string' ? JSON.parse(req.body) : req.body ) as Partial - const folders = await prisma.dashboardFolder.update({ - where: { id_ownerId: { id, ownerId: user.id } }, + const folders = await prisma.dashboardFolder.updateMany({ + where: { + id, + workspace: { members: { some: { userId: user.id } } }, + }, data, }) return res.send({ typebots: folders }) diff --git a/apps/builder/pages/api/integrations/google-sheets/spreadsheets/[id]/sheets.ts b/apps/builder/pages/api/integrations/google-sheets/spreadsheets/[id]/sheets.ts index e1f423bc663..e12d36526c4 100644 --- a/apps/builder/pages/api/integrations/google-sheets/spreadsheets/[id]/sheets.ts +++ b/apps/builder/pages/api/integrations/google-sheets/spreadsheets/[id]/sheets.ts @@ -18,7 +18,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'GET') { const credentialsId = req.query.credentialsId as string | undefined if (!credentialsId) return badRequest(res) - const spreadsheetId = req.query.id.toString() + const spreadsheetId = req.query.id as string const doc = new GoogleSpreadsheet(spreadsheetId) const auth = await getAuthenticatedGoogleClient(user.id, credentialsId) if (!auth) diff --git a/apps/builder/pages/api/stripe/checkout.ts b/apps/builder/pages/api/stripe/checkout.ts index 1fc2cc7d0a6..9a09163024c 100644 --- a/apps/builder/pages/api/stripe/checkout.ts +++ b/apps/builder/pages/api/stripe/checkout.ts @@ -10,8 +10,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2020-08-27', }) - const { email, currency } = + const { email, currency, plan, workspaceId } = typeof req.body === 'string' ? JSON.parse(req.body) : req.body + + console.log(plan, workspaceId) const session = await stripe.checkout.sessions.create({ success_url: `${req.headers.origin}/typebots?stripe=success`, cancel_url: `${req.headers.origin}/typebots?stripe=cancel`, @@ -19,12 +21,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { allow_promotion_codes: true, customer_email: email, mode: 'subscription', + metadata: { workspaceId, plan }, line_items: [ { - price: - currency === 'eur' - ? process.env.STRIPE_PRICE_EUR_ID - : process.env.STRIPE_PRICE_USD_ID, + price: getPrice(plan, currency), quantity: 1, }, ], @@ -34,4 +34,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { return methodNotAllowed(res) } +const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => { + if (plan === 'team') + return currency === 'eur' + ? process.env.STRIPE_PRICE_TEAM_EUR_ID + : process.env.STRIPE_PRICE_TEAM_USD_ID + return currency === 'eur' + ? process.env.STRIPE_PRICE_EUR_ID + : process.env.STRIPE_PRICE_USD_ID +} + export default withSentry(handler) diff --git a/apps/builder/pages/api/stripe/customer-portal.ts b/apps/builder/pages/api/stripe/customer-portal.ts index e50a174bb34..e8776c155df 100644 --- a/apps/builder/pages/api/stripe/customer-portal.ts +++ b/apps/builder/pages/api/stripe/customer-portal.ts @@ -1,25 +1,36 @@ -import { User } from 'db' import { NextApiRequest, NextApiResponse } from 'next' -import { getSession } from 'next-auth/react' -import { methodNotAllowed } from 'utils' +import { + badRequest, + forbidden, + methodNotAllowed, + notAuthenticated, +} from 'utils' import Stripe from 'stripe' import { withSentry } from '@sentry/nextjs' +import { getAuthenticatedUser } from 'services/api/utils' +import prisma from 'libs/prisma' +import { WorkspaceRole } from 'db' const handler = async (req: NextApiRequest, res: NextApiResponse) => { - const session = await getSession({ req }) - if (!session?.user) - return res.status(401).json({ message: 'Not authenticated' }) - const user = session.user as User - if (!user.stripeId) - return res.status(401).json({ message: 'Not authenticated' }) + const user = await getAuthenticatedUser(req) + if (!user) return notAuthenticated(res) if (req.method === 'GET') { + const workspaceId = req.query.workspaceId as string | undefined + if (!workspaceId) return badRequest(res) if (!process.env.STRIPE_SECRET_KEY) throw Error('STRIPE_SECRET_KEY var is missing') + const workspace = await prisma.workspace.findFirst({ + where: { + id: workspaceId, + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + }) + if (!workspace?.stripeId) return forbidden(res) const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2020-08-27', }) const session = await stripe.billingPortal.sessions.create({ - customer: user.stripeId, + customer: workspace.stripeId, return_url: req.headers.referer, }) res.redirect(session.url) diff --git a/apps/builder/pages/api/stripe/webhook.ts b/apps/builder/pages/api/stripe/webhook.ts index 606d0560539..d3b0f5eaf1d 100644 --- a/apps/builder/pages/api/stripe/webhook.ts +++ b/apps/builder/pages/api/stripe/webhook.ts @@ -40,36 +40,44 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session - const { customer_email } = session - if (!customer_email) - return res.status(500).send(`customer_email not found`) - await prisma.user.update({ - where: { email: customer_email }, - data: { plan: Plan.PRO, stripeId: session.customer as string }, + const { metadata } = session + if (!metadata?.workspaceId || !metadata?.plan) + return res.status(500).send({ message: `customer_email not found` }) + await prisma.workspace.update({ + where: { id: metadata.workspaceId }, + data: { + plan: metadata.plan === 'team' ? Plan.TEAM : Plan.PRO, + stripeId: session.customer as string, + }, }) - return res.status(200).send({ message: 'user upgraded in DB' }) + return res.status(200).send({ message: 'workspace upgraded in DB' }) } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription - await prisma.user.update({ + const { metadata } = subscription + if (!metadata.workspaceId) + return res.status(500).send(`workspaceId not found`) + await prisma.workspace.update({ where: { - stripeId: subscription.customer as string, + id: metadata.workspaceId, }, data: { plan: Plan.FREE, }, }) - return res.status(200).send({ message: 'user downgraded in DB' }) + return res.send({ message: 'workspace downgraded in DB' }) } default: { return res.status(304).send({ message: 'event not handled' }) } } } catch (err) { + console.error(err) if (err instanceof Error) { console.error(err) return res.status(400).send(`Webhook Error: ${err.message}`) } + return res.status(500).send(`Error occured: ${err}`) } } return methodNotAllowed(res) diff --git a/apps/builder/pages/api/typebots.ts b/apps/builder/pages/api/typebots.ts index 15cafddddb9..3a8ad82e5b0 100644 --- a/apps/builder/pages/api/typebots.ts +++ b/apps/builder/pages/api/typebots.ts @@ -1,28 +1,30 @@ import { withSentry } from '@sentry/nextjs' -import { Prisma } from 'db' +import { Prisma, WorkspaceRole } from 'db' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' import { getAuthenticatedUser } from 'services/api/utils' import { parseNewTypebot } from 'services/typebots/typebots' -import { methodNotAllowed, notAuthenticated } from 'utils' +import { badRequest, methodNotAllowed, notAuthenticated } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) try { if (req.method === 'GET') { + const workspaceId = req.query.workspaceId as string | undefined const folderId = req.query.allFolders ? undefined : req.query.folderId ? req.query.folderId.toString() : null + if (!workspaceId) return badRequest(res) const typebotIds = req.query.typebotIds as string[] | undefined if (typebotIds) { const typebots = await prisma.typebot.findMany({ where: { OR: [ { - ownerId: user.id, + workspace: { members: { some: { userId: user.id } } }, id: { in: typebotIds }, }, { @@ -42,8 +44,29 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } const typebots = await prisma.typebot.findMany({ where: { - ownerId: user.id, - folderId, + OR: [ + { + folderId, + workspace: { + id: workspaceId, + members: { + some: { + userId: user.id, + role: { not: WorkspaceRole.GUEST }, + }, + }, + }, + }, + { + workspace: { + id: workspaceId, + members: { + some: { userId: user.id, role: WorkspaceRole.GUEST }, + }, + }, + collaborators: { some: { userId: user.id } }, + }, + ], }, orderBy: { createdAt: 'desc' }, select: { name: true, publishedTypebotId: true, id: true, icon: true }, diff --git a/apps/builder/pages/api/typebots/[typebotId].ts b/apps/builder/pages/api/typebots/[typebotId].ts index e59fa5c1037..95244320d3b 100644 --- a/apps/builder/pages/api/typebots/[typebotId].ts +++ b/apps/builder/pages/api/typebots/[typebotId].ts @@ -16,26 +16,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { where: canReadTypebot(typebotId, user), include: { publishedTypebot: true, - owner: { select: { email: true, name: true, image: true } }, collaborators: { select: { userId: true, type: true } }, webhooks: true, }, }) if (!typebot) return res.send({ typebot: null }) - const { - publishedTypebot, - owner, - collaborators, - webhooks, - ...restOfTypebot - } = typebot + const { publishedTypebot, collaborators, webhooks, ...restOfTypebot } = + typebot const isReadOnly = collaborators.find((c) => c.userId === user.id)?.type === CollaborationType.READ return res.send({ typebot: restOfTypebot, publishedTypebot, - owner, isReadOnly, webhooks, }) diff --git a/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts b/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts index 8d206708291..09220481514 100644 --- a/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts +++ b/apps/builder/pages/api/typebots/[typebotId]/collaborators/[userId].ts @@ -1,33 +1,28 @@ import { withSentry } from '@sentry/nextjs' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' -import { canWriteTypebot } from 'services/api/dbRules' +import { canEditGuests } from 'services/api/dbRules' import { getAuthenticatedUser } from 'services/api/utils' -import { forbidden, methodNotAllowed, notAuthenticated } from '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 - const typebot = await prisma.typebot.findFirst({ - where: canWriteTypebot(typebotId, user), - }) - if (!typebot) return forbidden(res) - if (req.method === 'PUT') { + if (req.method === 'PATCH') { const data = req.body - await prisma.collaboratorsOnTypebots.upsert({ - where: { userId_typebotId: { typebotId, userId } }, - create: data, - update: data, + await prisma.collaboratorsOnTypebots.updateMany({ + where: { userId, typebot: canEditGuests(user, typebotId) }, + data: { type: data.type }, }) return res.send({ message: 'success', }) } if (req.method === 'DELETE') { - await prisma.collaboratorsOnTypebots.delete({ - where: { userId_typebotId: { typebotId, userId } }, + await prisma.collaboratorsOnTypebots.deleteMany({ + where: { userId, typebot: canEditGuests(user, typebotId) }, }) return res.send({ message: 'success', diff --git a/apps/builder/pages/api/typebots/[typebotId]/invitations.ts b/apps/builder/pages/api/typebots/[typebotId]/invitations.ts index deced7a4c86..63471470273 100644 --- a/apps/builder/pages/api/typebots/[typebotId]/invitations.ts +++ b/apps/builder/pages/api/typebots/[typebotId]/invitations.ts @@ -1,6 +1,6 @@ import { withSentry } from '@sentry/nextjs' import { invitationToCollaborate } from 'assets/emails/invitationToCollaborate' -import { CollaborationType } from 'db' +import { CollaborationType, WorkspaceRole } from 'db' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules' @@ -30,7 +30,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const typebot = await prisma.typebot.findFirst({ where: canWriteTypebot(typebotId, user), }) - if (!typebot) return forbidden(res) + if (!typebot || !typebot.workspaceId) return forbidden(res) const { email, type } = (req.body as | { email: string | undefined; type: CollaborationType | undefined } @@ -40,7 +40,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { where: { email: email.toLowerCase() }, select: { id: true }, }) - if (existingUser) + if (existingUser) { await prisma.collaboratorsOnTypebots.create({ data: { type, @@ -48,7 +48,21 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { userId: existingUser.id, }, }) - else + await prisma.memberInWorkspace.upsert({ + where: { + userId_workspaceId: { + userId: existingUser.id, + workspaceId: typebot.workspaceId, + }, + }, + create: { + role: WorkspaceRole.GUEST, + userId: existingUser.id, + workspaceId: typebot.workspaceId, + }, + update: {}, + }) + } else await prisma.invitation.create({ data: { email: email.toLowerCase(), type, typebotId }, }) diff --git a/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts b/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts index 06f881efe76..9baf47ae61d 100644 --- a/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts +++ b/apps/builder/pages/api/typebots/[typebotId]/invitations/[email].ts @@ -1,33 +1,32 @@ import { withSentry } from '@sentry/nextjs' +import { Invitation } from 'db' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' -import { canWriteTypebot } from 'services/api/dbRules' +import { canEditGuests } from 'services/api/dbRules' import { getAuthenticatedUser } from 'services/api/utils' -import { forbidden, methodNotAllowed, notAuthenticated } from '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 - const typebot = await prisma.typebot.findFirst({ - where: canWriteTypebot(typebotId, user), - }) - if (!typebot) return forbidden(res) - if (req.method === 'PUT') { - const data = req.body - await prisma.invitation.upsert({ - where: { email_typebotId: { email, typebotId } }, - create: data, - update: data, + if (req.method === 'PATCH') { + const data = req.body as Invitation + await prisma.invitation.updateMany({ + where: { email, typebot: canEditGuests(user, typebotId) }, + data: { type: data.type }, }) return res.send({ message: 'success', }) } if (req.method === 'DELETE') { - await prisma.invitation.delete({ - where: { email_typebotId: { email, typebotId } }, + await prisma.invitation.deleteMany({ + where: { + email, + typebot: canEditGuests(user, typebotId), + }, }) return res.send({ message: 'success', diff --git a/apps/builder/pages/api/typebots/[typebotId]/results.ts b/apps/builder/pages/api/typebots/[typebotId]/results.ts index 0254a73ffb4..0d41b5e7491 100644 --- a/apps/builder/pages/api/typebots/[typebotId]/results.ts +++ b/apps/builder/pages/api/typebots/[typebotId]/results.ts @@ -3,13 +3,19 @@ import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules' import { getAuthenticatedUser } from 'services/api/utils' -import { isFreePlan } from 'services/user/user' -import { methodNotAllowed, notAuthenticated } from 'utils' +import { isFreePlan } from 'services/workspace' +import { forbidden, methodNotAllowed, notAuthenticated } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await getAuthenticatedUser(req) if (!user) return notAuthenticated(res) + const workspaceId = req.query.workspaceId as string | undefined if (req.method === 'GET') { + const workspace = await prisma.workspace.findFirst({ + where: { id: workspaceId, members: { some: { userId: user.id } } }, + select: { plan: true }, + }) + if (!workspace) return forbidden(res) const typebotId = req.query.typebotId.toString() const lastResultId = req.query.lastResultId?.toString() const take = parseInt(req.query.limit?.toString()) @@ -24,7 +30,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { where: { typebot: canReadTypebot(typebotId, user), answers: { some: {} }, - isCompleted: isFreePlan(user) ? true : undefined, + isCompleted: isFreePlan(workspace) ? true : undefined, }, orderBy: { createdAt: 'desc', diff --git a/apps/builder/pages/api/users/[id]/sharedTypebots.ts b/apps/builder/pages/api/users/[id]/sharedTypebots.ts deleted file mode 100644 index 196bcddaa5b..00000000000 --- a/apps/builder/pages/api/users/[id]/sharedTypebots.ts +++ /dev/null @@ -1,38 +0,0 @@ -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, - icon: true, - }, - }, - }, - }) - return res.send({ - sharedTypebots: sharedTypebots.map((typebot) => ({ ...typebot.typebot })), - }) - } - methodNotAllowed(res) -} - -export default withSentry(handler) diff --git a/apps/builder/pages/api/webhooks/[webhookId].ts b/apps/builder/pages/api/webhooks/[webhookId].ts index 0e9418975bd..687f3eeb261 100644 --- a/apps/builder/pages/api/webhooks/[webhookId].ts +++ b/apps/builder/pages/api/webhooks/[webhookId].ts @@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { id: webhookId, typebot: { OR: [ - { ownerId: user.id }, + { workspace: { members: { some: { userId: user.id } } } }, { collaborators: { some: { @@ -40,7 +40,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const typebot = await prisma.typebot.findFirst({ where: { OR: [ - { id: data.typebotId, ownerId: user.id }, + { + id: data.typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, { collaborators: { some: { diff --git a/apps/builder/pages/api/workspaces.ts b/apps/builder/pages/api/workspaces.ts new file mode 100644 index 00000000000..15f7558e775 --- /dev/null +++ b/apps/builder/pages/api/workspaces.ts @@ -0,0 +1,35 @@ +import { withSentry } from '@sentry/nextjs' +import { Workspace } from 'db' +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) + if (req.method === 'GET') { + const workspaces = await prisma.workspace.findMany({ + where: { members: { some: { userId: user.id } } }, + include: { members: true }, + orderBy: { createdAt: 'asc' }, + }) + console.log(workspaces) + return res.send({ workspaces }) + } + if (req.method === 'POST') { + const data = req.body as Workspace + const workspace = await prisma.workspace.create({ + data: { + ...data, + members: { create: [{ role: 'ADMIN', userId: user.id }] }, + }, + }) + return res.status(200).json({ + workspace, + }) + } + methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/builder/pages/api/workspaces/[workspaceId].ts b/apps/builder/pages/api/workspaces/[workspaceId].ts new file mode 100644 index 00000000000..6b828ce0c6e --- /dev/null +++ b/apps/builder/pages/api/workspaces/[workspaceId].ts @@ -0,0 +1,28 @@ +import { withSentry } from '@sentry/nextjs' +import { Workspace, WorkspaceRole } from 'db' +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) + if (req.method === 'PATCH') { + const id = req.query.workspaceId as string + const updates = req.body as Partial + const updatedWorkspace = await prisma.workspace.updateMany({ + where: { + id, + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + data: updates, + }) + return res.status(200).json({ + workspace: updatedWorkspace, + }) + } + methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts b/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts new file mode 100644 index 00000000000..b44829401fb --- /dev/null +++ b/apps/builder/pages/api/workspaces/[workspaceId]/invitations.ts @@ -0,0 +1,47 @@ +import { withSentry } from '@sentry/nextjs' +import { WorkspaceInvitation, WorkspaceRole } from 'db' +import prisma from 'libs/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getAuthenticatedUser } from 'services/api/utils' +import { forbidden, methodNotAllowed, notAuthenticated } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await getAuthenticatedUser(req) + if (!user) return notAuthenticated(res) + if (req.method === 'POST') { + const data = req.body as Omit + const existingUser = await prisma.user.findUnique({ + where: { email: data.email }, + }) + const workspace = await prisma.workspace.findFirst({ + where: { + id: data.workspaceId, + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + }) + if (!workspace) return forbidden(res) + if (existingUser) { + await prisma.memberInWorkspace.create({ + data: { + role: data.type, + workspaceId: data.workspaceId, + userId: existingUser.id, + }, + }) + return res.send({ + member: { + userId: existingUser.id, + name: existingUser.name, + email: existingUser.email, + role: data.type, + workspaceId: data.workspaceId, + }, + }) + } + const invitation = await prisma.workspaceInvitation.create({ data }) + return res.send({ invitation }) + } + methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/invitations/[id].ts b/apps/builder/pages/api/workspaces/[workspaceId]/invitations/[id].ts new file mode 100644 index 00000000000..21fd7170627 --- /dev/null +++ b/apps/builder/pages/api/workspaces/[workspaceId]/invitations/[id].ts @@ -0,0 +1,39 @@ +import { withSentry } from '@sentry/nextjs' +import { WorkspaceInvitation, WorkspaceRole } from 'db' +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) + if (req.method === 'PATCH') { + const data = req.body as Omit + const invitation = await prisma.workspaceInvitation.updateMany({ + where: { + id: data.id, + workspace: { + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + }, + data, + }) + return res.send({ invitation }) + } + if (req.method === 'DELETE') { + const id = req.query.id as string + await prisma.workspaceInvitation.deleteMany({ + where: { + id, + workspace: { + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + }, + }) + return res.send({ message: 'success' }) + } + methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/members.ts b/apps/builder/pages/api/workspaces/[workspaceId]/members.ts new file mode 100644 index 00000000000..7e9c3ed7807 --- /dev/null +++ b/apps/builder/pages/api/workspaces/[workspaceId]/members.ts @@ -0,0 +1,42 @@ +import { withSentry } from '@sentry/nextjs' +import prisma from 'libs/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getAuthenticatedUser } from 'services/api/utils' +import { methodNotAllowed, notAuthenticated, notFound } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await getAuthenticatedUser(req) + if (!user) return notAuthenticated(res) + if (req.method === 'GET') { + const id = req.query.workspaceId as string + const workspace = await prisma.workspace.findFirst({ + where: { + id, + members: { some: { userId: user.id } }, + }, + include: { + members: { + include: { + user: true, + }, + }, + invitations: true, + }, + }) + if (!workspace) return notFound(res) + return res.send({ + members: workspace.members.map((member) => ({ + userId: member.userId, + role: member.role, + workspaceId: member.workspaceId, + email: member.user.email, + image: member.user.image, + name: member.user.name, + })), + invitations: workspace.invitations, + }) + } + methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/builder/pages/api/workspaces/[workspaceId]/members/[id].ts b/apps/builder/pages/api/workspaces/[workspaceId]/members/[id].ts new file mode 100644 index 00000000000..ac36b522696 --- /dev/null +++ b/apps/builder/pages/api/workspaces/[workspaceId]/members/[id].ts @@ -0,0 +1,48 @@ +import { withSentry } from '@sentry/nextjs' +import { MemberInWorkspace, WorkspaceRole } from 'db' +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) + if (req.method === 'PATCH') { + const workspaceId = req.query.workspaceId as string + const memberId = req.query.id as string + const updates = req.body as Partial + const member = await prisma.memberInWorkspace.updateMany({ + where: { + userId: memberId, + workspace: { + id: workspaceId, + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + }, + data: { role: updates.role }, + }) + return res.status(200).json({ + member, + }) + } + if (req.method === 'DELETE') { + const workspaceId = req.query.workspaceId as string + const memberId = req.query.id as string + const member = await prisma.memberInWorkspace.deleteMany({ + where: { + userId: memberId, + workspace: { + id: workspaceId, + members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } }, + }, + }, + }) + return res.status(200).json({ + member, + }) + } + methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/builder/pages/typebots.tsx b/apps/builder/pages/typebots.tsx index f4939eab004..ecb7d639bbb 100644 --- a/apps/builder/pages/typebots.tsx +++ b/apps/builder/pages/typebots.tsx @@ -10,26 +10,32 @@ import { Spinner, useToast } from '@chakra-ui/react' import { pay } from 'services/stripe' import { useUser } from 'contexts/UserContext' import { NextPageContext } from 'next/types' +import { useWorkspace } from 'contexts/WorkspaceContext' const DashboardPage = () => { const [isLoading, setIsLoading] = useState(false) const { query, isReady } = useRouter() const { user } = useUser() + const { workspace } = useWorkspace() const toast = useToast({ position: 'top-right', status: 'success', }) useEffect(() => { - const subscribe = query.subscribe?.toString() - if (subscribe && user && user.plan === 'FREE') { + const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined + if (workspace && subscribePlan && user && user.plan === 'FREE') { setIsLoading(true) - pay( + pay({ user, - navigator.languages.find((l) => l.includes('fr')) ? 'eur' : 'usd' - ) + plan: subscribePlan, + workspaceId: workspace.id, + currency: navigator.languages.find((l) => l.includes('fr')) + ? 'eur' + : 'usd', + }) } - }, [query.subscribe, user]) + }, [query, user, workspace]) useEffect(() => { if (!isReady) return @@ -38,7 +44,7 @@ const DashboardPage = () => { if (stripeStatus === 'success') toast({ - title: 'Typebot Pro', + title: 'Payment successful', description: "You've successfully subscribed 🎉", }) if (couponCode) { diff --git a/apps/builder/pages/typebots/shared.tsx b/apps/builder/pages/typebots/shared.tsx deleted file mode 100644 index 40584714f0b..00000000000 --- a/apps/builder/pages/typebots/shared.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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/services/database.ts b/apps/builder/playwright/services/database.ts index b22fe7e738a..47747e4fefa 100644 --- a/apps/builder/playwright/services/database.ts +++ b/apps/builder/playwright/services/database.ts @@ -11,18 +11,36 @@ import { CollaborationType, DashboardFolder, GraphNavigation, + Plan, PrismaClient, User, + WorkspaceRole, } from 'db' import { readFileSync } from 'fs' import { encrypt } from 'utils' const prisma = new PrismaClient() +const proWorkspaceId = 'proWorkspace' +export const freeWorkspaceId = 'freeWorkspace' +export const sharedWorkspaceId = 'sharedWorkspace' +export const guestWorkspaceId = 'guestWorkspace' + export const teardownDatabase = async () => { const ownerFilter = { - where: { ownerId: { in: ['freeUser', 'proUser'] } }, + where: { + workspace: { + members: { some: { userId: { in: ['freeUser', 'proUser'] } } }, + }, + }, } + await prisma.workspace.deleteMany({ + where: { + members: { + some: { userId: { in: ['freeUser', 'proUser'] } }, + }, + }, + }) await prisma.user.deleteMany({ where: { id: { in: ['freeUser', 'proUser'] } }, }) @@ -37,23 +55,75 @@ export const setupDatabase = async () => { return createCredentials() } -export const createUsers = () => - prisma.user.createMany({ - data: [ - { - id: 'freeUser', - email: 'free-user@email.com', - name: 'Free user', - graphNavigation: GraphNavigation.TRACKPAD, +export const createUsers = async () => { + await prisma.user.create({ + data: { + id: 'proUser', + email: 'pro-user@email.com', + name: 'Pro user', + graphNavigation: GraphNavigation.TRACKPAD, + workspaces: { + create: { + role: WorkspaceRole.ADMIN, + workspace: { + create: { + id: proWorkspaceId, + name: "Pro user's workspace", + plan: Plan.TEAM, + }, + }, + }, }, - { - id: 'proUser', - email: 'pro-user@email.com', - name: 'Pro user', - graphNavigation: GraphNavigation.TRACKPAD, + }, + }) + await prisma.user.create({ + data: { + id: 'freeUser', + email: 'free-user@email.com', + name: 'Free user', + graphNavigation: GraphNavigation.TRACKPAD, + workspaces: { + create: { + role: WorkspaceRole.ADMIN, + workspace: { + create: { + id: freeWorkspaceId, + name: "Free user's workspace", + plan: Plan.FREE, + }, + }, + }, }, - ], + }, + }) + await prisma.workspace.create({ + data: { + id: 'free', + name: 'Free workspace', + plan: Plan.FREE, + members: { + createMany: { + data: [{ role: WorkspaceRole.ADMIN, userId: 'proUser' }], + }, + }, + }, }) + return prisma.workspace.create({ + data: { + id: sharedWorkspaceId, + name: 'Shared Workspace', + plan: Plan.TEAM, + members: { + createMany: { + data: [ + { role: WorkspaceRole.MEMBER, userId: 'proUser' }, + { role: WorkspaceRole.ADMIN, userId: 'freeUser' }, + ], + }, + }, + }, + }) +} export const createWebhook = async ( typebotId: string, @@ -91,7 +161,7 @@ export const createTypebots = async (partialTypebots: Partial[]) => { export const createFolders = (partialFolders: Partial[]) => prisma.dashboardFolder.createMany({ data: partialFolders.map((folder) => ({ - ownerId: 'proUser', + workspaceId: proWorkspaceId, name: 'Folder #1', ...folder, })), @@ -110,9 +180,9 @@ const createCredentials = () => { data: [ { name: 'pro-user@email.com', - ownerId: 'proUser', type: CredentialsType.GOOGLE_SHEETS, data: encryptedData, + workspaceId: proWorkspaceId, iv, }, ], @@ -179,9 +249,10 @@ const parseTypebotToPublicTypebot = ( const parseTestTypebot = (partialTypebot: Partial): Typebot => ({ id: partialTypebot.id ?? 'typebot', + ownerId: 'proUser', + workspaceId: proWorkspaceId, folderId: null, name: 'My typebot', - ownerId: 'proUser', theme: defaultTheme, settings: defaultSettings, publicId: null, @@ -243,8 +314,9 @@ export const importTypebotInDatabase = async ( ) => { const typebot: any = { ...JSON.parse(readFileSync(path).toString()), - ...updates, + workspaceId: proWorkspaceId, ownerId: 'proUser', + ...updates, } await prisma.typebot.create({ data: typebot, diff --git a/apps/builder/playwright/tests/accountSettings.spec.ts b/apps/builder/playwright/tests/accountSettings.spec.ts new file mode 100644 index 00000000000..2e9fcf43c93 --- /dev/null +++ b/apps/builder/playwright/tests/accountSettings.spec.ts @@ -0,0 +1,28 @@ +import test, { expect } from '@playwright/test' +import path from 'path' + +// Can't test the update features because of the auth mocking. +test('should display user info properly', async ({ page }) => { + await page.goto('/typebots') + await page.click('text=Settings & Members') + const saveButton = page.locator('button:has-text("Save")') + await expect(saveButton).toBeHidden() + await expect( + page.locator('input[type="email"]').getAttribute('disabled') + ).toBeDefined() + await page.fill('#name', 'John Doe') + expect(saveButton).toBeVisible() + await page.setInputFiles( + 'input[type="file"]', + path.join(__dirname, '../fixtures/avatar.jpg') + ) + await expect(page.locator('img >> nth=1')).toHaveAttribute( + 'src', + new RegExp( + `http://localhost:9000/typebot/public/users/proUser/avatar`, + 'gm' + ) + ) + await page.click('text="Preferences"') + await expect(page.locator('text=Trackpad')).toBeVisible() +}) diff --git a/apps/builder/playwright/tests/collaboration.spec.ts b/apps/builder/playwright/tests/collaboration.spec.ts index 85d4a0a7f89..db9b1771ec2 100644 --- a/apps/builder/playwright/tests/collaboration.spec.ts +++ b/apps/builder/playwright/tests/collaboration.spec.ts @@ -1,35 +1,41 @@ import test, { expect } from '@playwright/test' import cuid from 'cuid' +import { CollaborationType, Plan, WorkspaceRole } from 'db' +import prisma from 'libs/prisma' import { InputStepType, defaultTextInputOptions } from 'models' -import path from 'path' import { createResults, createTypebots, parseDefaultBlockWithStep, } from '../services/database' -const typebotId = cuid() - -test.beforeAll(async () => { - await createTypebots([ - { - id: typebotId, - name: 'Shared typebot', - ownerId: 'freeUser', - ...parseDefaultBlockWithStep({ - type: InputStepType.TEXT, - options: defaultTextInputOptions, - }), - }, - ]) - await createResults({ typebotId }) -}) - test.describe('Typebot owner', () => { - test.use({ - storageState: path.join(__dirname, '../freeUser.json'), - }) test('Can invite collaborators', async ({ page }) => { + const typebotId = cuid() + const guestWorkspaceId = cuid() + await prisma.workspace.create({ + data: { + id: guestWorkspaceId, + name: 'Guest Workspace', + plan: Plan.FREE, + members: { + createMany: { + data: [{ role: WorkspaceRole.ADMIN, userId: 'proUser' }], + }, + }, + }, + }) + await createTypebots([ + { + id: typebotId, + name: 'Guest typebot', + workspaceId: guestWorkspaceId, + ...parseDefaultBlockWithStep({ + type: InputStepType.TEXT, + options: defaultTextInputOptions, + }), + }, + ]) await page.goto(`/typebots/${typebotId}/edit`) await page.click('button[aria-label="Show collaboration menu"]') await expect(page.locator('text=Free user')).toBeHidden() @@ -44,13 +50,12 @@ test.describe('Typebot owner', () => { await expect(page.locator('text=Free user')).toBeHidden() await page.fill( 'input[placeholder="colleague@company.com"]', - 'pro-user@email.com' + 'free-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() @@ -59,17 +64,47 @@ test.describe('Typebot owner', () => { 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') + const typebotId = cuid() + const guestWorkspaceId = cuid() + await prisma.workspace.create({ + data: { + id: guestWorkspaceId, + name: 'Guest Workspace #2', + plan: Plan.FREE, + members: { + createMany: { + data: [{ role: WorkspaceRole.GUEST, userId: 'proUser' }], + }, + }, + }, + }) + await createTypebots([ + { + id: typebotId, + name: 'Guest typebot', + workspaceId: guestWorkspaceId, + ...parseDefaultBlockWithStep({ + type: InputStepType.TEXT, + options: defaultTextInputOptions, + }), + }, + ]) + await prisma.collaboratorsOnTypebots.create({ + data: { + typebotId, + userId: 'proUser', + type: CollaborationType.READ, + }, + }) + await createResults({ typebotId }) + await page.goto(`/typebots`) + await page.click("text=Pro user's workspace") + await page.click('text=Guest workspace #2') + await page.click('text=Guest typebot') await page.click('button[aria-label="Show collaboration menu"]') - await page.click('text=Pro user') + await page.click('text=Everyone at Guest workspace') await expect(page.locator('text="Remove"')).toBeHidden() - await expect(page.locator('text=Free user')).toBeVisible() + await expect(page.locator('text=Pro user')).toBeVisible() await page.click('text=Block #1', { force: true }) await expect(page.locator('input[value="Block #1"]')).toBeHidden() await page.goto(`/typebots/${typebotId}/results`) diff --git a/apps/builder/playwright/tests/customDomains.spec.ts b/apps/builder/playwright/tests/customDomains.spec.ts index 2e8cf25c30f..62dfbbd2317 100644 --- a/apps/builder/playwright/tests/customDomains.spec.ts +++ b/apps/builder/playwright/tests/customDomains.spec.ts @@ -1,63 +1,67 @@ import test, { expect } from '@playwright/test' import { InputStepType, defaultTextInputOptions } from 'models' -import { createTypebots, parseDefaultBlockWithStep } from '../services/database' +import { + createTypebots, + freeWorkspaceId, + parseDefaultBlockWithStep, +} from '../services/database' import path from 'path' import cuid from 'cuid' -const typebotId = cuid() -test.describe('Dashboard page', () => { - test('should be able to connect custom domain', async ({ page }) => { +test('should be able to connect custom domain', async ({ page }) => { + const typebotId = cuid() + await createTypebots([ + { + id: typebotId, + ...parseDefaultBlockWithStep({ + type: InputStepType.TEXT, + options: defaultTextInputOptions, + }), + }, + ]) + await page.goto(`/typebots/${typebotId}/share`) + await page.click('text=Add my domain') + await page.click('text=Connect new') + await page.fill('input[placeholder="bot.my-domain.com"]', 'test') + await expect(page.locator('text=Save')).toBeDisabled() + await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com') + await expect(page.locator('text="A"')).toBeVisible() + await page.fill('input[placeholder="bot.my-domain.com"]', 'sub.yolozeeer.com') + await expect(page.locator('text="CNAME"')).toBeVisible() + await page.click('text=Save') + await expect(page.locator('text="https://sub.yolozeeer.com/"')).toBeVisible() + await page.click('text="Edit" >> nth=1') + await page.fill('text=https://sub.yolozeeer.com/Copy >> input', 'custom-path') + await page.press( + 'text=https://sub.yolozeeer.com/custom-path >> input', + 'Enter' + ) + await expect(page.locator('text="custom-path"')).toBeVisible() + await page.click('[aria-label="Remove custom domain"]') + await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden() + await page.click('button >> text=Add my domain') + await page.click('[aria-label="Remove domain"]') + await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden() +}) + +test.describe('Free workspace', () => { + test.use({ + storageState: path.join(__dirname, '../freeUser.json'), + }) + test("Add my domain shouldn't be available", async ({ page }) => { + const typebotId = cuid() await createTypebots([ { id: typebotId, + workspaceId: freeWorkspaceId, ...parseDefaultBlockWithStep({ type: InputStepType.TEXT, options: defaultTextInputOptions, }), }, ]) - await page.goto(`/typebots/${typebotId}/share`) await page.click('text=Add my domain') - await page.click('text=Connect new') - await page.fill('input[placeholder="bot.my-domain.com"]', 'test') - await expect(page.locator('text=Save')).toBeDisabled() - await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com') - await expect(page.locator('text="A"')).toBeVisible() - await page.fill( - 'input[placeholder="bot.my-domain.com"]', - 'sub.yolozeeer.com' - ) - await expect(page.locator('text="CNAME"')).toBeVisible() - await page.click('text=Save') - await expect( - page.locator('text="https://sub.yolozeeer.com/"') - ).toBeVisible() - await page.click('text="Edit" >> nth=1') - await page.fill( - 'text=https://sub.yolozeeer.com/Copy >> input', - 'custom-path' - ) - await page.press( - 'text=https://sub.yolozeeer.com/custom-path >> input', - 'Enter' - ) - await expect(page.locator('text="custom-path"')).toBeVisible() - await page.click('[aria-label="Remove custom domain"]') - await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden() - await page.click('button >> text=Add my domain') - await page.click('[aria-label="Remove domain"]') - await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden() - }) - - test.describe('Free user', () => { - test.use({ - storageState: path.join(__dirname, '../freeUser.json'), - }) - test("Add my domain shouldn't be available", async ({ page }) => { - await page.goto(`/typebots/${typebotId}/share`) - await page.click('text=Add my domain') - await expect(page.locator('text=Upgrade now')).toBeVisible() - }) + await expect(page.locator('text=For solo creator')).toBeVisible() }) }) diff --git a/apps/builder/playwright/tests/dashboard.spec.ts b/apps/builder/playwright/tests/dashboard.spec.ts index 2013420a228..3287ff8a2b0 100644 --- a/apps/builder/playwright/tests/dashboard.spec.ts +++ b/apps/builder/playwright/tests/dashboard.spec.ts @@ -80,11 +80,10 @@ test.describe('Dashboard page', () => { }) test("create folder shouldn't be available", async ({ page }) => { await page.goto('/typebots') + await page.click('text=Shared workspace') + await page.click('text=Free workspace') await page.click('text=Create a folder') - await expect( - page.locator('text="You can\'t create folders with the basic plan"') - ).toBeVisible() - await expect(page.locator('text=Upgrade now')).toBeVisible() + await expect(page.locator('text=For solo creator')).toBeVisible() }) }) }) diff --git a/apps/builder/playwright/tests/results.spec.ts b/apps/builder/playwright/tests/results.spec.ts index 851fe6367d5..8dd6879b2de 100644 --- a/apps/builder/playwright/tests/results.spec.ts +++ b/apps/builder/playwright/tests/results.spec.ts @@ -1,6 +1,7 @@ import test, { expect, Page } from '@playwright/test' import cuid from 'cuid' import { readFileSync } from 'fs' +import prisma from 'libs/prisma' import { defaultTextInputOptions, InputStepType } from 'models' import { parse } from 'papaparse' import path from 'path' @@ -113,14 +114,18 @@ test.describe('Results page', () => { validateExportAll(dataAll) }) - test.describe('Free user', () => { + test.describe('Free user', async () => { test.use({ storageState: path.join(__dirname, '../freeUser.json'), }) test("Incomplete results shouldn't be displayed", async ({ page }) => { + await prisma.typebot.update({ + where: { id: typebotId }, + data: { workspaceId: 'free' }, + }) await page.goto(`/typebots/${typebotId}/results`) await page.click('text=Unlock') - await expect(page.locator('text=Upgrade now')).toBeVisible() + await expect(page.locator('text=For solo creator')).toBeVisible() }) }) }) diff --git a/apps/builder/playwright/tests/settings.spec.ts b/apps/builder/playwright/tests/settings.spec.ts index 4fabcf2ae18..202873d1eb4 100644 --- a/apps/builder/playwright/tests/settings.spec.ts +++ b/apps/builder/playwright/tests/settings.spec.ts @@ -124,13 +124,14 @@ test.describe.parallel('Settings page', () => { path.join(__dirname, '../fixtures/typebots/settings.json'), { id: typebotId, + workspaceId: 'free', } ) await page.goto(`/typebots/${typebotId}/settings`) await page.click('button:has-text("General")') await expect(page.locator('text=Pro')).toBeVisible() await page.click('text=Typebot.io branding') - await expect(page.locator('text=Upgrade now')).toBeVisible() + await expect(page.locator('text=For solo creator')).toBeVisible() }) }) }) diff --git a/apps/builder/playwright/tests/workspaces.spec.ts b/apps/builder/playwright/tests/workspaces.spec.ts new file mode 100644 index 00000000000..7299c49a3ce --- /dev/null +++ b/apps/builder/playwright/tests/workspaces.spec.ts @@ -0,0 +1,134 @@ +import test, { expect } from '@playwright/test' +import cuid from 'cuid' +import { defaultTextInputOptions, InputStepType } from 'models' +import { + createTypebots, + parseDefaultBlockWithStep, + sharedWorkspaceId, +} from '../services/database' + +const proTypebotId = cuid() +const freeTypebotId = cuid() + +test.beforeAll(async () => { + await createTypebots([{ id: proTypebotId, name: 'Pro typebot' }]) + await createTypebots([ + { + id: freeTypebotId, + name: 'Shared typebot', + workspaceId: sharedWorkspaceId, + ...parseDefaultBlockWithStep({ + type: InputStepType.TEXT, + options: { + ...defaultTextInputOptions, + labels: { + ...defaultTextInputOptions.labels, + placeholder: 'Hey there', + }, + }, + }), + }, + ]) +}) + +test('can switch between workspaces and access typebot', async ({ page }) => { + await page.goto('/typebots') + await expect(page.locator('text="Pro typebot"')).toBeVisible() + await page.click("text=Pro user's workspace") + await page.click('text=Shared workspace') + await expect(page.locator('text="Pro typebot"')).toBeHidden() + await page.click('text="Shared typebot"') + await expect(page.locator('text="Hey there"')).toBeVisible() +}) + +test('can create a new workspace', async ({ page }) => { + await page.goto('/typebots') + await page.click("text=Pro user's workspace") + await expect( + page.locator('text="Pro user\'s workspace" >> nth=1') + ).toBeHidden() + await page.click('text=New workspace') + await expect(page.locator('text="Pro typebot"')).toBeHidden() + await page.click("text=Pro user's workspace") + await expect( + page.locator('text="Pro user\'s workspace" >> nth=1') + ).toBeVisible() +}) + +test('can update workspace info', async ({ page }) => { + await page.goto('/typebots') + await page.click('text=Settings & Members') + await page.click('text="Settings"') + await page.click('[data-testid="editable-icon"]') + await page.fill('input[placeholder="Search..."]', 'building') + await page.click('text="🏦"') + await page.fill( + 'input[value="Pro user\'s workspace"]', + 'My awesome workspace' + ) +}) + +test('can manage members', async ({ page }) => { + await page.goto('/typebots') + await page.click('text=Settings & Members') + await page.click('text="Members"') + await expect(page.locator('text="pro-user@email.com"')).toBeVisible() + await expect(page.locator('button >> text="Invite"')).toBeEnabled() + await page.fill( + 'input[placeholder="colleague@company.com"]', + 'guest@email.com' + ) + await page.click('button >> text="Invite"') + await expect(page.locator('button >> text="Invite"')).toBeEnabled() + await expect( + page.locator('input[placeholder="colleague@company.com"]') + ).toHaveAttribute('value', '') + await expect(page.locator('text="guest@email.com"')).toBeVisible() + await expect(page.locator('text="Pending"')).toBeVisible() + await page.fill( + 'input[placeholder="colleague@company.com"]', + 'free-user@email.com' + ) + await page.click('text="Member" >> nth=0') + await page.click('text="Admin"') + await page.click('button >> text="Invite"') + await expect( + page.locator('input[placeholder="colleague@company.com"]') + ).toHaveAttribute('value', '') + await expect(page.locator('text="free-user@email.com"')).toBeVisible() + await expect(page.locator('text="Free user"')).toBeVisible() + + // Downgrade admin to member + await page.click('text="free-user@email.com"') + await page.click('button >> text="Member"') + await expect(page.locator('[data-testid="tag"] >> text="Admin"')).toHaveCount( + 1 + ) + await page.click('text="free-user@email.com"') + await page.click('button >> text="Remove"') + await expect(page.locator('text="free-user@email.com"')).toBeHidden() + + await page.click('text="guest@email.com"') + await page.click('text="Admin" >> nth=-1') + await expect(page.locator('[data-testid="tag"] >> text="Admin"')).toHaveCount( + 2 + ) + await page.click('text="guest@email.com"') + await page.click('button >> text="Remove"') + await expect(page.locator('text="guest@email.com"')).toBeHidden() +}) + +test("can't edit workspace as a member", async ({ page }) => { + await page.goto('/typebots') + await page.click("text=Pro user's workspace") + await page.click('text=Shared workspace') + await page.click('text=Settings & Members') + await expect(page.locator('text="Settings"')).toBeHidden() + await page.click('text="Members"') + await expect(page.locator('text="free-user@email.com"')).toBeVisible() + await expect( + page.locator('input[placeholder="colleague@company.com"]') + ).toBeHidden() + await page.click('text="free-user@email.com"') + await expect(page.locator('button >> text="Remove"')).toBeHidden() +}) diff --git a/apps/builder/services/api/dbRules.ts b/apps/builder/services/api/dbRules.ts index ab5312c5427..f7153dcd294 100644 --- a/apps/builder/services/api/dbRules.ts +++ b/apps/builder/services/api/dbRules.ts @@ -1,4 +1,4 @@ -import { CollaborationType, Prisma, User } from 'db' +import { CollaborationType, Prisma, User, WorkspaceRole } from 'db' const parseWhereFilter = ( typebotIds: string[] | string, @@ -6,14 +6,6 @@ const parseWhereFilter = ( type: 'read' | 'write' ): Prisma.TypebotWhereInput => ({ OR: [ - { - id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds }, - ownerId: - (type === 'read' && user.email === process.env.ADMIN_EMAIL) || - process.env.NEXT_PUBLIC_E2E_TEST - ? undefined - : user.id, - }, { id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds }, collaborators: { @@ -23,6 +15,18 @@ const parseWhereFilter = ( }, }, }, + { + id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds }, + workspace: + (type === 'read' && user.email === process.env.ADMIN_EMAIL) || + process.env.NEXT_PUBLIC_E2E_TEST + ? undefined + : { + members: { + some: { userId: user.id, role: { not: WorkspaceRole.GUEST } }, + }, + }, + }, ], }) @@ -37,3 +41,12 @@ export const canReadTypebots = (typebotIds: string[], user: User) => export const canWriteTypebots = (typebotIds: string[], user: User) => parseWhereFilter(typebotIds, user, 'write') + +export const canEditGuests = (user: User, typebotId: string) => ({ + id: typebotId, + workspace: { + members: { + some: { userId: user.id, role: { not: WorkspaceRole.GUEST } }, + }, + }, +}) diff --git a/apps/builder/services/user/credentials.ts b/apps/builder/services/credentials.ts similarity index 62% rename from apps/builder/services/user/credentials.ts rename to apps/builder/services/credentials.ts index e19b6a76be3..89a525148ac 100644 --- a/apps/builder/services/user/credentials.ts +++ b/apps/builder/services/credentials.ts @@ -1,17 +1,18 @@ import { Credentials } from 'models' +import { stringify } from 'qs' import useSWR from 'swr' import { sendRequest } from 'utils' -import { fetcher } from '../utils' +import { fetcher } from './utils' export const useCredentials = ({ - userId, + workspaceId, onError, }: { - userId?: string + workspaceId?: string onError?: (error: Error) => void }) => { const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>( - userId ? `/api/users/${userId}/credentials` : null, + workspaceId ? `/api/credentials?${stringify({ workspaceId })}` : null, fetcher ) if (error && onError) onError(error) @@ -23,24 +24,25 @@ export const useCredentials = ({ } export const createCredentials = async ( - userId: string, - credentials: Omit + credentials: Omit ) => sendRequest<{ credentials: Credentials }>({ - url: `/api/users/${userId}/credentials`, + url: `/api/credentials?${stringify({ + workspaceId: credentials.workspaceId, + })}`, method: 'POST', body: credentials, }) export const deleteCredentials = async ( - userId: string, + workspaceId: string, credentialsId: string ) => sendRequest<{ credentials: Credentials }>({ - url: `/api/users/${userId}/credentials/${credentialsId}`, + url: `/api/credentials/${credentialsId}?${stringify({ workspaceId })}`, method: 'DELETE', }) diff --git a/apps/builder/services/user/customDomains.ts b/apps/builder/services/customDomains.ts similarity index 57% rename from apps/builder/services/user/customDomains.ts rename to apps/builder/services/customDomains.ts index 3ac34b38c93..beb56babd89 100644 --- a/apps/builder/services/user/customDomains.ts +++ b/apps/builder/services/customDomains.ts @@ -1,20 +1,24 @@ import { CustomDomain } from 'db' import { Credentials } from 'models' +import { stringify } from 'qs' import useSWR from 'swr' import { sendRequest } from 'utils' -import { fetcher } from '../utils' +import { fetcher } from './utils' export const useCustomDomains = ({ - userId, + workspaceId, onError, }: { - userId?: string + workspaceId?: string onError: (error: Error) => void }) => { const { data, error, mutate } = useSWR< - { customDomains: Omit[] }, + { customDomains: Omit[] }, Error - >(userId ? `/api/users/${userId}/customDomains` : null, fetcher) + >( + workspaceId ? `/api/customDomains?${stringify({ workspaceId })}` : null, + fetcher + ) if (error) onError(error) return { customDomains: data?.customDomains, @@ -24,24 +28,24 @@ export const useCustomDomains = ({ } export const createCustomDomain = async ( - userId: string, - customDomain: Omit + workspaceId: string, + customDomain: Omit ) => sendRequest<{ credentials: Credentials }>({ - url: `/api/users/${userId}/customDomains`, + url: `/api/customDomains?${stringify({ workspaceId })}`, method: 'POST', body: customDomain, }) export const deleteCustomDomain = async ( - userId: string, + workspaceId: string, customDomain: string ) => sendRequest<{ credentials: Credentials }>({ - url: `/api/users/${userId}/customDomains/${customDomain}`, + url: `/api/customDomains/${customDomain}?${stringify({ workspaceId })}`, method: 'DELETE', }) diff --git a/apps/builder/services/folders.ts b/apps/builder/services/folders.ts index cd88cb0a864..26ed9ecb885 100644 --- a/apps/builder/services/folders.ts +++ b/apps/builder/services/folders.ts @@ -6,14 +6,16 @@ import { sendRequest } from 'utils' export const useFolders = ({ parentId, + workspaceId, onError, }: { + workspaceId?: string parentId?: string onError: (error: Error) => void }) => { - const params = stringify({ parentId }) + const params = stringify({ parentId, workspaceId }) const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>( - `/api/folders?${params}`, + workspaceId ? `/api/folders?${params}` : null, fetcher, { dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined } ) @@ -45,12 +47,13 @@ export const useFolderContent = ({ } export const createFolder = async ( + workspaceId: string, folder: Pick ) => sendRequest({ url: `/api/folders`, method: 'POST', - body: folder, + body: { ...folder, workspaceId }, }) export const deleteFolder = async (id: string) => diff --git a/apps/builder/services/integrations.ts b/apps/builder/services/integrations.ts index 369fb2f2fca..eaac6c6cdc5 100644 --- a/apps/builder/services/integrations.ts +++ b/apps/builder/services/integrations.ts @@ -11,9 +11,10 @@ import { export const getGoogleSheetsConsentScreenUrl = ( redirectUrl: string, - stepId: string + stepId: string, + workspaceId?: string ) => { - const queryParams = stringify({ redirectUrl, stepId }) + const queryParams = stringify({ redirectUrl, stepId, workspaceId }) return `/api/credentials/google-sheets/consent-url?${queryParams}` } diff --git a/apps/builder/services/publicTypebot.tsx b/apps/builder/services/publicTypebot.tsx index beb0ea3cb3d..a0b94555404 100644 --- a/apps/builder/services/publicTypebot.tsx +++ b/apps/builder/services/publicTypebot.tsx @@ -38,6 +38,7 @@ export const parsePublicTypebotToTypebot = ( folderId: existingTypebot.folderId, ownerId: existingTypebot.ownerId, icon: existingTypebot.icon, + workspaceId: existingTypebot.workspaceId, }) export const createPublishedTypebot = async (typebot: PublicTypebot) => diff --git a/apps/builder/services/stripe.ts b/apps/builder/services/stripe.ts index 87a916b7fe4..4c510dfbf5a 100644 --- a/apps/builder/services/stripe.ts +++ b/apps/builder/services/stripe.ts @@ -2,14 +2,21 @@ import { User } from 'db' import { loadStripe } from '@stripe/stripe-js' import { sendRequest } from 'utils' -export const pay = async (user: User, currency: 'usd' | 'eur') => { +type Props = { + user: User + currency: 'usd' | 'eur' + plan: 'pro' | 'team' + workspaceId: string +} + +export const pay = async ({ user, currency, plan, workspaceId }: Props) => { if (!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY) throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env') const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY) const { data, error } = await sendRequest<{ sessionId: string }>({ method: 'POST', url: '/api/stripe/checkout', - body: { email: user.email, currency }, + body: { email: user.email, currency, plan, workspaceId }, }) if (error || !data) return return stripe?.redirectToCheckout({ diff --git a/apps/builder/services/typebots/collaborators.ts b/apps/builder/services/typebots/collaborators.ts index 81f92fc0cbf..d2c7078054f 100644 --- a/apps/builder/services/typebots/collaborators.ts +++ b/apps/builder/services/typebots/collaborators.ts @@ -36,7 +36,7 @@ export const updateCollaborator = ( collaborator: CollaboratorsOnTypebots ) => sendRequest({ - method: 'PUT', + method: 'PATCH', url: `/api/typebots/${typebotId}/collaborators/${userId}`, body: collaborator, }) diff --git a/apps/builder/services/typebots/invitations.ts b/apps/builder/services/typebots/invitations.ts index 6cfc09ca653..cc4b52c35f4 100644 --- a/apps/builder/services/typebots/invitations.ts +++ b/apps/builder/services/typebots/invitations.ts @@ -36,10 +36,10 @@ export const sendInvitation = ( export const updateInvitation = ( typebotId: string, email: string, - invitation: Omit + invitation: Omit ) => sendRequest({ - method: 'PUT', + method: 'PATCH', url: `/api/typebots/${typebotId}/invitations/${email}`, body: invitation, }) diff --git a/apps/builder/services/typebots/typebots.ts b/apps/builder/services/typebots/typebots.ts index 7fb072bdb9b..98665ca471f 100644 --- a/apps/builder/services/typebots/typebots.ts +++ b/apps/builder/services/typebots/typebots.ts @@ -64,18 +64,20 @@ export type TypebotInDashboard = Pick< > export const useTypebots = ({ folderId, + workspaceId, allFolders, onError, }: { + workspaceId?: string folderId?: string allFolders?: boolean onError: (error: Error) => void }) => { - const params = stringify({ folderId, allFolders }) + const params = stringify({ folderId, allFolders, workspaceId }) const { data, error, mutate } = useSWR< { typebots: TypebotInDashboard[] }, Error - >(`/api/typebots?${params}`, fetcher, { + >(workspaceId ? `/api/typebots?${params}` : null, fetcher, { dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined, }) if (error) onError(error) @@ -88,10 +90,12 @@ export const useTypebots = ({ export const createTypebot = async ({ folderId, -}: Pick) => { + workspaceId, +}: Pick) => { const typebot = { folderId, name: 'My typebot', + workspaceId, } return sendRequest({ url: `/api/typebots`, @@ -379,13 +383,13 @@ export const parseDefaultPublicId = (name: string, id: string) => toKebabCase(name) + `-${id?.slice(-7)}` export const parseNewTypebot = ({ - ownerId, folderId, name, ownerAvatarUrl, + workspaceId, }: { - ownerId: string folderId: string | null + workspaceId: string name: string ownerAvatarUrl?: string }): Omit< @@ -413,9 +417,10 @@ export const parseNewTypebot = ({ steps: [startStep], } return { + ownerId: null, folderId, name, - ownerId, + workspaceId, blocks: [startBlock], edges: [], variables: [], diff --git a/apps/builder/services/user/index.ts b/apps/builder/services/user/index.ts index a9f712d7634..bbf65f9a54c 100644 --- a/apps/builder/services/user/index.ts +++ b/apps/builder/services/user/index.ts @@ -1,3 +1,3 @@ export * from './user' -export * from './customDomains' -export * from './credentials' +export * from '../customDomains' +export * from '../credentials' diff --git a/apps/builder/services/user/sharedTypebots.ts b/apps/builder/services/user/sharedTypebots.ts deleted file mode 100644 index 21f91239920..00000000000 --- a/apps/builder/services/user/sharedTypebots.ts +++ /dev/null @@ -1,47 +0,0 @@ -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< - Typebot, - 'name' | 'id' | 'publishedTypebotId' | 'icon' - >[] - }, - 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/user.ts b/apps/builder/services/user/user.ts index 6f924d3bf9b..08ae904c983 100644 --- a/apps/builder/services/user/user.ts +++ b/apps/builder/services/user/user.ts @@ -1,5 +1,5 @@ -import { Plan, User } from 'db' -import { isNotDefined, sendRequest } from 'utils' +import { User } from 'db' +import { sendRequest } from 'utils' export const updateUser = async (id: string, user: User) => sendRequest({ @@ -7,6 +7,3 @@ export const updateUser = async (id: string, user: User) => method: 'PUT', body: user, }) - -export const isFreePlan = (user?: User) => - isNotDefined(user) || user?.plan === Plan.FREE diff --git a/apps/builder/services/workspace/index.ts b/apps/builder/services/workspace/index.ts new file mode 100644 index 00000000000..6cc219daf29 --- /dev/null +++ b/apps/builder/services/workspace/index.ts @@ -0,0 +1,3 @@ +export * from './workspace' +export * from './member' +export * from './invitation' diff --git a/apps/builder/services/workspace/invitation.ts b/apps/builder/services/workspace/invitation.ts new file mode 100644 index 00000000000..d7047270420 --- /dev/null +++ b/apps/builder/services/workspace/invitation.ts @@ -0,0 +1,28 @@ +import { WorkspaceInvitation } from 'db' +import { sendRequest } from 'utils' +import { Member } from './member' + +export const sendInvitation = ( + invitation: Omit +) => + sendRequest<{ invitation?: WorkspaceInvitation; member?: Member }>({ + url: `/api/workspaces/${invitation.workspaceId}/invitations`, + method: 'POST', + body: invitation, + }) + +export const updateInvitation = (invitation: Partial) => + sendRequest({ + url: `/api/workspaces/${invitation.workspaceId}/invitations/${invitation.id}`, + method: 'PATCH', + body: invitation, + }) + +export const deleteInvitation = (invitation: { + workspaceId: string + id: string +}) => + sendRequest({ + url: `/api/workspaces/${invitation.workspaceId}/invitations/${invitation.id}`, + method: 'DELETE', + }) diff --git a/apps/builder/services/workspace/member.ts b/apps/builder/services/workspace/member.ts new file mode 100644 index 00000000000..158aca6aa08 --- /dev/null +++ b/apps/builder/services/workspace/member.ts @@ -0,0 +1,41 @@ +import { MemberInWorkspace, WorkspaceInvitation } from 'db' +import { fetcher } from 'services/utils' +import useSWR from 'swr' +import { sendRequest } from 'utils' + +export type Member = MemberInWorkspace & { + name: string | null + image: string | null + email: string | null +} + +export const useMembers = ({ workspaceId }: { workspaceId?: string }) => { + const { data, error, mutate } = useSWR< + { members: Member[]; invitations: WorkspaceInvitation[] }, + Error + >(workspaceId ? `/api/workspaces/${workspaceId}/members` : null, fetcher, { + dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined, + }) + return { + members: data?.members, + invitations: data?.invitations, + isLoading: !error && !data, + mutate, + } +} + +export const updateMember = ( + workspaceId: string, + member: Partial +) => + sendRequest({ + method: 'PATCH', + url: `/api/workspaces/${workspaceId}/members/${member.userId}`, + body: member, + }) + +export const deleteMember = (workspaceId: string, userId: string) => + sendRequest({ + method: 'DELETE', + url: `/api/workspaces/${workspaceId}/members/${userId}`, + }) diff --git a/apps/builder/services/workspace/workspace.ts b/apps/builder/services/workspace/workspace.ts new file mode 100644 index 00000000000..e78ee664dee --- /dev/null +++ b/apps/builder/services/workspace/workspace.ts @@ -0,0 +1,58 @@ +import { WorkspaceWithMembers } from 'contexts/WorkspaceContext' +import { Plan, Workspace } from 'db' +import useSWR from 'swr' +import { isNotDefined, sendRequest } from 'utils' +import { fetcher } from '../utils' + +export const useWorkspaces = ({ userId }: { userId?: string }) => { + const { data, error, mutate } = useSWR< + { + workspaces: WorkspaceWithMembers[] + }, + Error + >(userId ? `/api/workspaces` : null, fetcher) + return { + workspaces: data?.workspaces, + isLoading: !error && !data, + mutate, + } +} + +export const createNewWorkspace = async ( + body: Omit +) => + sendRequest<{ + workspace: Workspace + }>({ + url: `/api/workspaces`, + method: 'POST', + body, + }) + +export const updateWorkspace = async (updates: Partial) => + sendRequest<{ + workspace: Workspace + }>({ + url: `/api/workspaces/${updates.id}`, + method: 'PATCH', + body: updates, + }) + +export const planToReadable = (plan?: Plan) => { + if (!plan) return + switch (plan) { + case Plan.FREE: + return 'Free' + case Plan.LIFETIME: + return 'Lifetime' + case Plan.OFFERED: + return 'Offered' + case Plan.PRO: + return 'Pro' + case Plan.TEAM: + return 'Team' + } +} + +export const isFreePlan = (workspace?: Pick) => + isNotDefined(workspace) || workspace?.plan === Plan.FREE diff --git a/apps/docs/docs/self-hosting/configuration.md b/apps/docs/docs/self-hosting/configuration.md index 73274576850..92326c6c73a 100644 --- a/apps/docs/docs/self-hosting/configuration.md +++ b/apps/docs/docs/self-hosting/configuration.md @@ -178,13 +178,15 @@ The related environment variables are listed here but you are probably not inter

Stripe

-| Parameter | Default | Description | -| ----------------------------- | ------- | --------------------- | -| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | -- | Stripe public key | -| STRIPE_SECRET_KEY | -- | Stripe secret key | -| STRIPE_PRICE_USD_ID | -- | Pro plan USD price id | -| STRIPE_PRICE_EUR_ID | -- | Pro plan EUR price id | -| STRIPE_WEBHOOK_SECRET | -- | Stripe Webhook secret | +| Parameter | Default | Description | +| ----------------------------- | ------- | ---------------------- | +| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | -- | Stripe public key | +| STRIPE_SECRET_KEY | -- | Stripe secret key | +| STRIPE_PRICE_USD_ID | -- | Pro plan USD price id | +| STRIPE_PRICE_EUR_ID | -- | Pro plan EUR price id | +| STRIPE_PRICE_TEAM_USD_ID | -- | Team plan USD price id | +| STRIPE_PRICE_TEAM_EUR_ID | -- | Team plan EUR price id | +| STRIPE_WEBHOOK_SECRET | -- | Stripe Webhook secret |

diff --git a/apps/landing-page/assets/icons/CheckCircleIcon.tsx b/apps/landing-page/assets/icons/CheckCircleIcon.tsx new file mode 100644 index 00000000000..8fee71fcad8 --- /dev/null +++ b/apps/landing-page/assets/icons/CheckCircleIcon.tsx @@ -0,0 +1,15 @@ +import Icon, { IconProps } from '@chakra-ui/icon' +import React from 'react' + +export const CheckCircleIcon = (props: IconProps) => ( + + Checkmark Circle + + +) diff --git a/apps/landing-page/assets/icons/CheckIcon.tsx b/apps/landing-page/assets/icons/CheckIcon.tsx index f63255bb9eb..0ae59cc7542 100644 --- a/apps/landing-page/assets/icons/CheckIcon.tsx +++ b/apps/landing-page/assets/icons/CheckIcon.tsx @@ -1,15 +1,15 @@ import Icon, { IconProps } from '@chakra-ui/icon' import React from 'react' +import { featherIconsBaseProps } from '.' export const CheckIcon = (props: IconProps) => ( - Checkmark Circle - + ) diff --git a/apps/landing-page/assets/icons/HelpCircleIcon.tsx b/apps/landing-page/assets/icons/HelpCircleIcon.tsx new file mode 100644 index 00000000000..7a4b6f6f24b --- /dev/null +++ b/apps/landing-page/assets/icons/HelpCircleIcon.tsx @@ -0,0 +1,10 @@ +import { IconProps, Icon } from '@chakra-ui/react' +import { featherIconsBaseProps } from './HamburgerIcon' + +export const HelpCircleIcon = (props: IconProps) => ( + + + + + +) diff --git a/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx b/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx new file mode 100644 index 00000000000..ca422225efa --- /dev/null +++ b/apps/landing-page/components/PricingPage/PlanComparisonTables.tsx @@ -0,0 +1,384 @@ +import { + TableContainer, + Table, + Thead, + Tr, + Th, + Tbody, + Td, + Text, + Stack, + StackProps, + HStack, + Tooltip, + chakra, + Button, + Heading, +} from '@chakra-ui/react' +import { CheckIcon } from 'assets/icons/CheckIcon' +import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' +import { NextChakraLink } from 'components/common/nextChakraAdapters/NextChakraLink' +import React from 'react' + +type Props = { + prices: { + personalPro: '$39' | '39€' | '' + team: '$99' | '99€' | '' + } +} & StackProps + +export const PlanComparisonTables = ({ prices, ...props }: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Usage + PersonalPersonal ProTeam
FormsUnlimitedUnlimitedUnlimited
Form submissionsUnlimitedUnlimitedUnlimited
MembersJust youJust youUnlimited
GuestsUnlimitedUnlimitedUnlimited
File uploads5 MBUnlimitedUnlimited
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Features + PersonalPersonal ProTeam
+ + + + + +
Starter templates + + + + + +
Webhooks + + + + + +
Google Sheets + + + + + +
Google Analytics + + + + + +
Send emails + + + + + +
Zapier + + + + + +
Pabbly Connect + + + + + +
Make.com + + + + + +
Custom Javascript & CSS + + + + + +
Export CSV + + + + + +
Custom domains + UnlimitedUnlimited
+ UnlimitedUnlimited
Remove branding + + + + +
+ + + + +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ Support + PersonalPersonal ProTeam
Priority support + + + + +
Feature request priority + + + + +
+
+ + + + Personal + + Free + + + + + + + Personal Pro + + + {prices.personalPro}{' '} + / month + + + + + + + + Team + + + {prices.team} / month + + + + + + +
+ ) +} + +const TdWithTooltip = ({ + text, + tooltip, +}: { + text: string + tooltip: string +}) => ( + + {text} + + + + + + +) diff --git a/apps/landing-page/components/PricingPage/PricingCard/index.tsx b/apps/landing-page/components/PricingPage/PricingCard/index.tsx index 0cd0b04dd00..c29d778dbe9 100644 --- a/apps/landing-page/components/PricingPage/PricingCard/index.tsx +++ b/apps/landing-page/components/PricingPage/PricingCard/index.tsx @@ -9,13 +9,14 @@ import { VStack, } from '@chakra-ui/react' import * as React from 'react' -import { CheckIcon } from '../../../assets/icons/CheckIcon' +import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon' import { Card, CardProps } from './Card' export interface PricingCardData { features: string[] name: string price: string + featureLabel?: string } interface PricingCardProps extends CardProps { @@ -35,7 +36,7 @@ export const PricingCard = (props: PricingCardProps) => { {icon} - + {name} @@ -55,15 +56,19 @@ export const PricingCard = (props: PricingCardProps) => { )} + + {data.featureLabel && ( + {data.featureLabel} + )} {features.map((feature, index) => ( {feature} diff --git a/apps/landing-page/components/common/TableCells.tsx b/apps/landing-page/components/common/TableCells.tsx index 3c4a79c859d..59709259a39 100644 --- a/apps/landing-page/components/common/TableCells.tsx +++ b/apps/landing-page/components/common/TableCells.tsx @@ -1,11 +1,11 @@ -import { CheckIcon } from 'assets/icons/CheckIcon' +import { CheckCircleIcon } from 'assets/icons/CheckCircleIcon' import { CloseIcon } from 'assets/icons/CloseIcon' import { Td, Text } from '@chakra-ui/react' import React, { ReactNode } from 'react' export const Yes = (props: { children?: ReactNode }) => ( - + {props.children && ( {props.children} diff --git a/apps/landing-page/pages/about.tsx b/apps/landing-page/pages/about.tsx index 271a707122e..1f5d8d90ca7 100644 --- a/apps/landing-page/pages/about.tsx +++ b/apps/landing-page/pages/about.tsx @@ -64,7 +64,7 @@ const AboutPage = () => {
You can use the tool for free but your forms will contain a "Made with Typebot" small badge that potentially gets people to know about the product. If you want to remove it and also have access to - other advanced features, you have to subscribe for $30 per month. + other advanced features, you have to subscribe for $39 per month.
If you have any questions, feel free to reach out to me at{' '} diff --git a/apps/landing-page/pages/pricing.tsx b/apps/landing-page/pages/pricing.tsx index 92287f2712a..89f2df81476 100644 --- a/apps/landing-page/pages/pricing.tsx +++ b/apps/landing-page/pages/pricing.tsx @@ -14,15 +14,26 @@ import { Header } from 'components/common/Header/Header' import { NextChakraLink } from 'components/common/nextChakraAdapters/NextChakraLink' import { SocialMetaTags } from 'components/common/SocialMetaTags' import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons' +import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables' import { PricingCard } from 'components/PricingPage/PricingCard' import { ActionButton } from 'components/PricingPage/PricingCard/ActionButton' import { useEffect, useState } from 'react' const Pricing = () => { - const [price, setPrice] = useState<'$30' | '25€' | ''>('') + const [price, setPrice] = useState<{ + personalPro: '$39' | '39€' | '' + team: '$99' | '99€' | '' + }>({ + personalPro: '', + team: '', + }) useEffect(() => { - setPrice(navigator.languages.find((l) => l.includes('fr')) ? '25€' : '$30') + setPrice( + navigator.languages.find((l) => l.includes('fr')) + ? { personalPro: '39€', team: '99€' } + : { personalPro: '$39', team: '$99' } + ) }, []) return ( @@ -49,12 +60,12 @@ const Pricing = () => { px={[4, 0]} mt={[20, 32]} w="full" - maxW="900px" + maxW="1200px" > { href="https://app.typebot.io/register" _hover={{ textDecor: 'none' }} > - Try now + Get started } /> { borderColor="orange.200" button={ @@ -99,10 +110,38 @@ const Pricing = () => { } /> + + Subscribe now + + } + />
- - Frequently asked questions - + + + Compare plans & features + + + + Frequently asked questions + + @@ -114,39 +153,6 @@ const Pricing = () => { const Faq = () => { return ( - - - - - How can I use Typebot with my team? - - - - - - Typebot allows you to invite your colleagues to collaborate on any of - your typebot. You can give him access as a reader or an editor. Your - colleague's account can be a free account.
-
- I'm working on a better solution for teams with shared workspaces and - other team-oriented features. -
-
- - - - - - How many seats will I have with the Pro plan? - - - - - - You'll have only one seat. You can invite your colleagues to - collaborate on your typebots even though they have a free account. - - diff --git a/apps/viewer/pages/api/typebots.ts b/apps/viewer/pages/api/typebots.ts index d7b928aea95..30478620090 100644 --- a/apps/viewer/pages/api/typebots.ts +++ b/apps/viewer/pages/api/typebots.ts @@ -9,7 +9,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await authenticateUser(req) if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebots = await prisma.typebot.findMany({ - where: { ownerId: user.id }, + where: { workspace: { members: { some: { userId: user.id } } } }, select: { name: true, publishedTypebotId: true, id: true }, }) return res.send({ typebots }) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts index 2ea8d11cc3e..30759b88288 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts @@ -6,13 +6,16 @@ import { parseSampleResult } from 'services/api/webhooks' import { methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (req.method === 'GET') { - const user = await authenticateUser(req) - if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() const stepId = req.query.blockId.toString() - const typebot = (await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = (await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, })) as unknown as Typebot | undefined if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) const step = typebot.blocks diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts index 9b067ef3b41..ebcb1256332 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts @@ -6,13 +6,16 @@ import { parseSampleResult } from 'services/api/webhooks' import { methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (req.method === 'GET') { - const user = await authenticateUser(req) - if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() const blockId = req.query.blockId.toString() - const typebot = (await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = (await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, })) as unknown as Typebot | undefined if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) const linkedTypebots = await getLinkedTypebots(typebot, user) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/subscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/subscribeWebhook.ts index f66a927521d..efc6b501182 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/subscribeWebhook.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/subscribeWebhook.ts @@ -6,9 +6,9 @@ import { authenticateUser } from 'services/api/utils' import { byId, methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (req.method === 'POST') { - const user = await authenticateUser(req) - if (!user) return res.status(401).json({ message: 'Not authenticated' }) const body = req.body as Record if (!('url' in body)) return res.status(403).send({ message: 'url is missing in body' }) @@ -16,8 +16,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const typebotId = req.query.typebotId.toString() const blockId = req.query.blockId.toString() const stepId = req.query.stepId.toString() - const typebot = (await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = (await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, })) as unknown as Typebot | undefined if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) try { diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/unsubscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/unsubscribeWebhook.ts index 5015610415c..3b1c16465f1 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/unsubscribeWebhook.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/unsubscribeWebhook.ts @@ -6,14 +6,17 @@ import { authenticateUser } from 'services/api/utils' import { byId, methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (req.method === 'POST') { - const user = await authenticateUser(req) - if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() const blockId = req.query.blockId.toString() const stepId = req.query.stepId.toString() - const typebot = (await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = (await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, })) as unknown as Typebot | undefined if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) try { diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts index fba8c2f3453..1d3318cc4f8 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts @@ -6,17 +6,20 @@ import { authenticateUser } from 'services/api/utils' import { byId, methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (req.method === 'POST') { - const user = await authenticateUser(req) - if (!user) return res.status(401).json({ message: 'Not authenticated' }) const body = req.body as Record if (!('url' in body)) return res.status(403).send({ message: 'url is missing in body' }) const { url } = body const typebotId = req.query.typebotId.toString() const stepId = req.query.blockId.toString() - const typebot = (await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = (await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, })) as unknown as Typebot | undefined if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) try { diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts index f0bdd480ca7..f9f0de7b66a 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts @@ -6,13 +6,16 @@ import { authenticateUser } from 'services/api/utils' import { byId, methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) if (req.method === 'POST') { - const user = await authenticateUser(req) - if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() const stepId = req.query.blockId.toString() - const typebot = (await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = (await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, })) as unknown as Typebot | undefined if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) try { diff --git a/apps/viewer/pages/api/typebots/[typebotId]/results.ts b/apps/viewer/pages/api/typebots/[typebotId]/results.ts index 54e9503b091..9ca4c4ebd9e 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/results.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/results.ts @@ -9,13 +9,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await authenticateUser(req) if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() - const typebot = await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, - }) - if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) const limit = Number(req.query.limit) const results = (await prisma.result.findMany({ - where: { typebotId: typebot.id }, + where: { + typebot: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, + }, orderBy: { createdAt: 'desc' }, take: limit, include: { answers: true }, diff --git a/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts index 804e3978373..8fe9c7cf0bd 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts @@ -10,8 +10,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await authenticateUser(req) if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() - const typebot = await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, select: { blocks: true, webhooks: true }, }) const emptyWebhookSteps = (typebot?.blocks as Block[]).reduce< diff --git a/apps/viewer/pages/api/typebots/[typebotId]/webhookSteps.ts b/apps/viewer/pages/api/typebots/[typebotId]/webhookSteps.ts index 8e1721caaf7..3dee0b9b87b 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/webhookSteps.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/webhookSteps.ts @@ -10,8 +10,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await authenticateUser(req) if (!user) return res.status(401).json({ message: 'Not authenticated' }) const typebotId = req.query.typebotId.toString() - const typebot = await prisma.typebot.findUnique({ - where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + const typebot = await prisma.typebot.findFirst({ + where: { + id: typebotId, + workspace: { members: { some: { userId: user.id } } }, + }, select: { blocks: true, webhooks: true }, }) const emptyWebhookSteps = (typebot?.blocks as Block[]).reduce< diff --git a/apps/viewer/playwright/services/database.ts b/apps/viewer/playwright/services/database.ts index fa0274f197a..f0c4b61db09 100644 --- a/apps/viewer/playwright/services/database.ts +++ b/apps/viewer/playwright/services/database.ts @@ -8,16 +8,21 @@ import { Typebot, Webhook, } from 'models' -import { PrismaClient } from 'db' +import { PrismaClient, WorkspaceRole } from 'db' import { readFileSync } from 'fs' import { encrypt } from 'utils' const prisma = new PrismaClient() +const proWorkspaceId = 'proWorkspaceViewer' + export const teardownDatabase = async () => { try { - await prisma.user.delete({ - where: { id: 'proUser' }, + await prisma.workspace.deleteMany({ + where: { members: { some: { userId: { in: ['proUser'] } } } }, + }) + await prisma.user.deleteMany({ + where: { id: { in: ['proUser'] } }, }) } catch (err) { console.error(err) @@ -34,6 +39,17 @@ export const createUser = () => email: 'user@email.com', name: 'User', apiToken: 'userToken', + workspaces: { + create: { + role: WorkspaceRole.ADMIN, + workspace: { + create: { + id: proWorkspaceId, + name: 'Pro workspace', + }, + }, + }, + }, }, }) @@ -81,6 +97,7 @@ const parseTestTypebot = (partialTypebot: Partial): Typebot => ({ folderId: null, name: 'My typebot', ownerId: 'proUser', + workspaceId: proWorkspaceId, icon: null, theme: defaultTheme, settings: defaultSettings, @@ -143,6 +160,7 @@ export const importTypebotInDatabase = async ( const typebot: any = { ...JSON.parse(readFileSync(path).toString()), ...updates, + workspaceId: proWorkspaceId, ownerId: 'proUser', } await prisma.typebot.create({ @@ -203,6 +221,7 @@ export const createSmtpCredentials = ( name: smtpData.from.email as string, type: CredentialsType.SMTP, ownerId: 'proUser', + workspaceId: proWorkspaceId, }, }) } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 721b1010c0f..9faf129d01b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -50,7 +50,7 @@ model User { sessions Session[] typebots Typebot[] folders DashboardFolder[] - plan Plan @default(FREE) + plan Plan? @default(FREE) stripeId String? @unique credentials Credentials[] customDomains CustomDomain[] @@ -59,6 +59,47 @@ model User { company String? onboardingCategories String[] graphNavigation GraphNavigation? + workspaces MemberInWorkspace[] +} + +model Workspace { + id String @id @default(cuid()) + name String + icon String? + members MemberInWorkspace[] + folders DashboardFolder[] + typebots Typebot[] + createdAt DateTime @default(now()) + plan Plan @default(FREE) + stripeId String? @unique + customDomains CustomDomain[] + credentials Credentials[] + invitations WorkspaceInvitation[] +} + +model MemberInWorkspace { + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + role WorkspaceRole + + @@unique([userId, workspaceId]) +} + +enum WorkspaceRole { + ADMIN + MEMBER + GUEST +} + +model WorkspaceInvitation { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + email String + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + type WorkspaceRole } enum GraphNavigation { @@ -67,21 +108,25 @@ enum GraphNavigation { } model CustomDomain { - name String @id - createdAt DateTime @default(now()) - ownerId String - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + name String @id + createdAt DateTime @default(now()) + ownerId String? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + workspaceId String? + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) } model Credentials { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - ownerId String - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - data String // Encrypted data - name String - type String - iv String + id String @id @default(cuid()) + createdAt DateTime @default(now()) + ownerId String? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + workspaceId String? + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + data String // Encrypted data + name String + type String + iv String @@unique([name, type, ownerId]) } @@ -89,6 +134,7 @@ model Credentials { enum Plan { FREE PRO + TEAM LIFETIME OFFERED } @@ -106,12 +152,14 @@ model DashboardFolder { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt name String - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - ownerId String + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String? parentFolderId String? parentFolder DashboardFolder? @relation("ParentChild", fields: [parentFolderId], references: [id]) childrenFolder DashboardFolder[] @relation("ParentChild") typebots Typebot[] + workspaceId String? + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@unique([id, ownerId]) } @@ -122,8 +170,8 @@ model Typebot { updatedAt DateTime @default(now()) @updatedAt icon String? name String - ownerId String - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) publishedTypebotId String? publishedTypebot PublicTypebot? results Result[] @@ -139,6 +187,8 @@ model Typebot { collaborators CollaboratorsOnTypebots[] invitations Invitation[] webhooks Webhook[] + workspaceId String? + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@unique([id, ownerId]) } @@ -166,6 +216,7 @@ model CollaboratorsOnTypebots { enum CollaborationType { READ WRITE + FULL_ACCESS } model PublicTypebot { diff --git a/packages/scripts/.env.local.example b/packages/scripts/.env.local.example index ea6bd64078b..71c1af2d4d1 100644 --- a/packages/scripts/.env.local.example +++ b/packages/scripts/.env.local.example @@ -1 +1,2 @@ -DATABASE_URL=postgresql://postgres:@localhost:5432/typebot \ No newline at end of file +DATABASE_URL=postgresql://postgres:typebot@localhost:5432/typebot +ENCRYPTION_SECRET= \ No newline at end of file diff --git a/packages/scripts/index.ts b/packages/scripts/index.ts index 58cdf2461ee..1abf960f24a 100644 --- a/packages/scripts/index.ts +++ b/packages/scripts/index.ts @@ -1,5 +1,5 @@ -import { PrismaClient } from 'db' import path from 'path' +import { migrateWorkspace } from './workspaceMigration' require('dotenv').config({ path: path.join( @@ -8,7 +8,8 @@ require('dotenv').config({ ), }) -const prisma = new PrismaClient() -const main = async () => {} +const main = async () => { + await migrateWorkspace() +} main().then() diff --git a/packages/scripts/package.json b/packages/scripts/package.json index ad3199513a2..64c1b1ac7c8 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -6,7 +6,8 @@ "private": true, "scripts": { "start:local": "ts-node index.ts", - "start:prod": "NODE_ENV=production ts-node index.ts" + "start:prod": "NODE_ENV=production ts-node index.ts", + "start:workspaces:migration": "ts-node workspaceMigration.ts" }, "devDependencies": { "db": "*", diff --git a/packages/scripts/workspaceMigration.ts b/packages/scripts/workspaceMigration.ts new file mode 100644 index 00000000000..a106b7f474a --- /dev/null +++ b/packages/scripts/workspaceMigration.ts @@ -0,0 +1,85 @@ +import { Plan, PrismaClient, WorkspaceRole } from 'db' +import path from 'path' + +const prisma = new PrismaClient() + +export const migrateWorkspace = async () => { + const users = await prisma.user.findMany({ + where: { workspaces: { none: {} } }, + include: { + folders: true, + typebots: true, + credentials: true, + customDomains: true, + CollaboratorsOnTypebots: { + include: { typebot: { select: { workspaceId: true } } }, + }, + }, + }) + let i = 1 + for (const user of users) { + console.log('Updating', user.email, `(${i}/${users.length})`) + i += 1 + const newWorkspace = await prisma.workspace.create({ + data: { + name: user.name ? `${user.name}'s workspace` : 'My workspace', + members: { create: { userId: user.id, role: WorkspaceRole.ADMIN } }, + stripeId: user.stripeId, + plan: user.plan ?? Plan.FREE, + }, + }) + await prisma.credentials.updateMany({ + where: { id: { in: user.credentials.map((c) => c.id) } }, + data: { workspaceId: newWorkspace.id, ownerId: null }, + }) + await prisma.customDomain.updateMany({ + where: { + name: { in: user.customDomains.map((c) => c.name) }, + ownerId: user.id, + }, + data: { workspaceId: newWorkspace.id, ownerId: null }, + }) + await prisma.dashboardFolder.updateMany({ + where: { + id: { in: user.folders.map((c) => c.id) }, + }, + data: { workspaceId: newWorkspace.id, ownerId: null }, + }) + await prisma.typebot.updateMany({ + where: { + id: { in: user.typebots.map((c) => c.id) }, + }, + data: { workspaceId: newWorkspace.id, ownerId: null }, + }) + for (const collab of user.CollaboratorsOnTypebots) { + if (!collab.typebot.workspaceId) continue + await prisma.memberInWorkspace.upsert({ + where: { + userId_workspaceId: { + userId: user.id, + workspaceId: collab.typebot.workspaceId, + }, + }, + create: { + role: WorkspaceRole.GUEST, + userId: user.id, + workspaceId: collab.typebot.workspaceId, + }, + update: {}, + }) + } + } +} + +require('dotenv').config({ + path: path.join( + __dirname, + process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local' + ), +}) + +const main = async () => { + await migrateWorkspace() +} + +main().then()