2
0

feat(editor): Team workspaces

This commit is contained in:
Baptiste Arnaud
2022-05-13 15:22:44 -07:00
parent 6c2986590b
commit f0fdf08b00
132 changed files with 3354 additions and 1228 deletions

View File

@ -335,9 +335,8 @@ export const invitationToCollaborate = (
color: #000000; color: #000000;
" "
> >
From now on you will see this From now on you will have access to this
typebot in your dashboard under typebot in their workspace 👍
the "Shared with me" button 👍
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -435,3 +435,19 @@ export const MouseIcon = (props: IconProps) => (
/> />
</Icon> </Icon>
) )
export const HardDriveIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="22" y1="12" x2="2" y2="12"></line>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
<line x1="6" y1="16" x2="6.01" y2="16"></line>
<line x1="10" y1="16" x2="10.01" y2="16"></line>
</Icon>
)
export const CreditCardIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</Icon>
)

View File

@ -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 = () => (
<Flex w="full" borderBottomWidth="1px" justify="center">
<Flex
justify="space-between"
alignItems="center"
h="16"
maxW="1000px"
flex="1"
>
<NextChakraLink
className="w-24"
href="/typebots"
data-testid="authenticated"
>
<TypebotLogo w="30px" />
</NextChakraLink>
</Flex>
</Flex>
)

View File

@ -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 (
<Stack direction="row" spacing="10" justifyContent={'space-between'}>
<Heading as="h2" fontSize="xl">
Billing
</Heading>
<Stack spacing="6" w="400px">
<HStack>
<Text>Your subscription</Text>
<SubscriptionTag plan={user?.plan} />
</HStack>
{user?.stripeId && (
<Button as={NextChakraLink} href="/api/stripe/customer-portal">
Manage my subscription
</Button>
)}
{user?.plan === Plan.FREE && <UpgradeButton />}
{user?.plan === Plan.FREE && (
<HStack as="form" onSubmit={handleCouponCodeRedeem}>
<Input name="coupon" placeholder="Coupon code..." />
<Button type="submit" isLoading={isLoading}>
Redeem
</Button>
</HStack>
)}
</Stack>
</Stack>
)
}

View File

@ -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<HTMLInputElement>) => {
updateUser({ name: e.target.value })
}
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ email: e.target.value })
}
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
return (
<Stack direction="row" spacing="10" justifyContent={'space-between'}>
<Heading as="h2" fontSize="xl">
Personal info
</Heading>
<Stack spacing="6" w="400px">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`public/users/${user?.id}/avatar`}
leftIcon={<UploadIcon />}
onFileUploaded={handleFileUploaded}
>
Change photo
</UploadButton>
<Text color="gray.500" fontSize="sm">
.jpg or.png, max 1MB
</Text>
</Stack>
</HStack>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
<Input
id="name"
value={user?.name ?? ''}
onChange={handleNameChange}
/>
</FormControl>
{isDefined(user?.email) && (
<Tooltip
label="Updating email is not available."
placement="left"
hasArrow
>
<FormControl>
<FormLabel
htmlFor="email"
color={isOAuthProvider ? 'gray.500' : 'current'}
>
Email address
</FormLabel>
<Input
id="email"
type="email"
isDisabled
value={user?.email ?? ''}
onChange={handleEmailChange}
/>
</FormControl>
</Tooltip>
)}
<FormControl>
<FormLabel htmlFor="name">API token</FormLabel>
<InputGroup>
<Input
id="token"
value={user?.apiToken ?? ''}
type={isApiTokenVisible ? 'text' : 'password'}
/>
<InputRightElement mr="3">
<Button size="xs" onClick={toggleTokenVisibility}>
{isApiTokenVisible ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
</Stack>
</Stack>
)
}

View File

@ -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 <Tag>Free plan</Tag>
}
case Plan.LIFETIME: {
return <Tag colorScheme="yellow">Lifetime plan</Tag>
}
case Plan.OFFERED: {
return <Tag colorScheme="yellow">Offered</Tag>
}
case Plan.PRO: {
return <Tag colorScheme="orange">Pro plan</Tag>
}
default: {
return <Tag>Free plan</Tag>
}
}
}

View File

@ -7,23 +7,40 @@ import {
Text, Text,
HStack, HStack,
Flex, Flex,
Avatar,
SkeletonCircle, SkeletonCircle,
Skeleton, Button,
useDisclosure,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos' import { TypebotLogo } from 'assets/logos'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' 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 { signOut } from 'next-auth/react'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
export const DashboardHeader = () => { export const DashboardHeader = () => {
const { user } = useUser() const { user } = useUser()
const { workspace, workspaces, switchWorkspace, createWorkspace } =
useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const handleLogOut = () => { const handleLogOut = () => {
localStorage.removeItem('workspaceId')
signOut() signOut()
} }
const handleCreateNewWorkspace = () =>
createWorkspace(user?.name ?? undefined)
return ( return (
<Flex w="full" borderBottomWidth="1px" justify="center"> <Flex w="full" borderBottomWidth="1px" justify="center">
<Flex <Flex
@ -40,34 +57,72 @@ export const DashboardHeader = () => {
> >
<TypebotLogo w="30px" /> <TypebotLogo w="30px" />
</NextChakraLink> </NextChakraLink>
<Menu> <HStack>
<MenuButton> {user && workspace && (
<HStack> <WorkspaceSettingsModal
<Skeleton isLoaded={user !== undefined}> isOpen={isOpen}
<Text>{user?.name}</Text> onClose={onClose}
</Skeleton> user={user}
<SkeletonCircle isLoaded={user !== undefined}> workspace={workspace}
<Avatar />
boxSize="35px" )}
name={user?.name ?? undefined} <Button leftIcon={<SettingsIcon />} onClick={onOpen}>
src={user?.image ?? undefined} Settings & Members
/> </Button>
</SkeletonCircle> <Menu placement="bottom-end">
</HStack> <MenuButton as={Button} variant="outline" px="2">
</MenuButton> <HStack>
<MenuList> <SkeletonCircle
<MenuItem isLoaded={workspace !== undefined}
as={NextChakraLink} alignItems="center"
href="/account" display="flex"
icon={<SettingsIcon />} boxSize="20px"
> >
My account <EmojiOrImageIcon
</MenuItem> boxSize="20px"
<MenuItem onClick={handleLogOut} icon={<LogOutIcon />}> icon={workspace?.icon}
Log out defaultIcon={HardDriveIcon}
</MenuItem> />
</MenuList> </SkeletonCircle>
</Menu> {workspace && (
<Text noOfLines={0} maxW="200px">
{workspace.name}
</Text>
)}
<ChevronLeftIcon transform="rotate(-90deg)" />
</HStack>
</MenuButton>
<MenuList>
{workspaces
?.filter((w) => w.id !== workspace?.id)
.map((workspace) => (
<MenuItem
key={workspace.id}
onClick={() => switchWorkspace(workspace.id)}
>
<HStack>
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="16px"
defaultIcon={HardDriveIcon}
/>
<Text>{workspace.name}</Text>
</HStack>
</MenuItem>
))}
<MenuItem onClick={handleCreateNewWorkspace} icon={<PlusIcon />}>
New workspace
</MenuItem>
<MenuItem
onClick={handleLogOut}
icon={<LogOutIcon />}
color="orange.500"
>
Log out
</MenuItem>
</MenuList>
</Menu>
</HStack>
</Flex> </Flex>
</Flex> </Flex>
) )

View File

@ -19,15 +19,14 @@ import {
TypebotInDashboard, TypebotInDashboard,
useTypebots, useTypebots,
} from 'services/typebots' } from 'services/typebots'
import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
import { BackButton } from './FolderContent/BackButton' import { BackButton } from './FolderContent/BackButton'
import { CreateBotButton } from './FolderContent/CreateBotButton' import { CreateBotButton } from './FolderContent/CreateBotButton'
import { CreateFolderButton } from './FolderContent/CreateFolderButton' import { CreateFolderButton } from './FolderContent/CreateFolderButton'
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton' import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
import { SharedTypebotsButton } from './FolderContent/SharedTypebotsButton'
import { TypebotButton } from './FolderContent/TypebotButton' import { TypebotButton } from './FolderContent/TypebotButton'
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay' import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
import { OnboardingModal } from './OnboardingModal' import { OnboardingModal } from './OnboardingModal'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = { folder: DashboardFolder | null } type Props = { folder: DashboardFolder | null }
@ -35,6 +34,7 @@ const dragDistanceTolerance = 20
export const FolderContent = ({ folder }: Props) => { export const FolderContent = ({ folder }: Props) => {
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreatingFolder, setIsCreatingFolder] = useState(false) const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const { const {
setDraggedTypebot, setDraggedTypebot,
@ -60,6 +60,7 @@ export const FolderContent = ({ folder }: Props) => {
isLoading: isFolderLoading, isLoading: isFolderLoading,
mutate: mutateFolders, mutate: mutateFolders,
} = useFolders({ } = useFolders({
workspaceId: workspace?.id,
parentId: folder?.id, parentId: folder?.id,
onError: (error) => { onError: (error) => {
toast({ title: "Couldn't fetch folders", description: error.message }) toast({ title: "Couldn't fetch folders", description: error.message })
@ -71,22 +72,13 @@ export const FolderContent = ({ folder }: Props) => {
isLoading: isTypebotLoading, isLoading: isTypebotLoading,
mutate: mutateTypebots, mutate: mutateTypebots,
} = useTypebots({ } = useTypebots({
workspaceId: workspace?.id,
folderId: folder?.id, folderId: folder?.id,
onError: (error) => { onError: (error) => {
toast({ title: "Couldn't fetch typebots", description: error.message }) 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) => { const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
if (!typebots) return if (!typebots) return
const { error } = await patchTypebot(typebotId, { const { error } = await patchTypebot(typebotId, {
@ -97,9 +89,9 @@ export const FolderContent = ({ folder }: Props) => {
} }
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!folders) return if (!folders || !workspace) return
setIsCreatingFolder(true) setIsCreatingFolder(true)
const { error, data: newFolder } = await createFolder({ const { error, data: newFolder } = await createFolder(workspace.id, {
parentFolderId: folder?.id ?? null, parentFolderId: folder?.id ?? null,
}) })
setIsCreatingFolder(false) setIsCreatingFolder(false)
@ -164,7 +156,7 @@ export const FolderContent = ({ folder }: Props) => {
return ( return (
<Flex w="full" flex="1" justify="center"> <Flex w="full" flex="1" justify="center">
{typebots && user && folder === null && ( {typebots && !isTypebotLoading && user && folder === null && (
<OnboardingModal totalTypebots={typebots.length} /> <OnboardingModal totalTypebots={typebots.length} />
)} )}
<Stack w="1000px" spacing={6}> <Stack w="1000px" spacing={6}>
@ -185,7 +177,6 @@ export const FolderContent = ({ folder }: Props) => {
isLoading={isTypebotLoading} isLoading={isTypebotLoading}
isFirstBot={typebots?.length === 0 && folder === null} isFirstBot={typebots?.length === 0 && folder === null}
/> />
{totalSharedTypebots > 0 && <SharedTypebotsButton />}
{isFolderLoading && <ButtonSkeleton />} {isFolderLoading && <ButtonSkeleton />}
{folders && {folders &&
folders.map((folder) => ( folders.map((folder) => (

View File

@ -2,18 +2,18 @@ import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
import { FolderPlusIcon } from 'assets/icons' import { FolderPlusIcon } from 'assets/icons'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal' import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal' import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
import { useUser } from 'contexts/UserContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react' import React from 'react'
import { isFreePlan } from 'services/user' import { isFreePlan } from 'services/workspace'
type Props = { isLoading: boolean; onClick: () => void } type Props = { isLoading: boolean; onClick: () => void }
export const CreateFolderButton = ({ isLoading, onClick }: Props) => { export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
const { user } = useUser() const { workspace } = useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const handleClick = () => { const handleClick = () => {
if (isFreePlan(user)) return onOpen() if (isFreePlan(workspace)) return onOpen()
onClick() onClick()
} }
return ( return (
@ -24,7 +24,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
> >
<HStack> <HStack>
<Text>Create a folder</Text> <Text>Create a folder</Text>
{isFreePlan(user) && <Tag colorScheme="orange">Pro</Tag>} {isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
</HStack> </HStack>
<UpgradeModal <UpgradeModal
isOpen={isOpen} isOpen={isOpen}

View File

@ -1,40 +0,0 @@
import React from 'react'
import { Button, Flex, Text, VStack, WrapItem } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { UsersIcon } from 'assets/icons'
export const SharedTypebotsButton = () => {
const router = useRouter()
const handleTypebotClick = () => router.push(`/typebots/shared`)
return (
<Button
as={WrapItem}
onClick={handleTypebotClick}
display="flex"
flexDir="column"
variant="outline"
color="gray.800"
w="225px"
h="270px"
mr={{ sm: 6 }}
mb={6}
rounded="lg"
whiteSpace="normal"
cursor="pointer"
>
<VStack spacing="4">
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
>
<UsersIcon fontSize="50" color="orange.300" />
</Flex>
<Text>Shared with me</Text>
</VStack>
</Button>
)
}

View File

@ -20,7 +20,7 @@ import { deleteTypebot, importTypebot, getTypebot } from 'services/typebots'
import { Typebot } from 'models' import { Typebot } from 'models'
import { useTypebotDnd } from 'contexts/TypebotDndContext' import { useTypebotDnd } from 'contexts/TypebotDndContext'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
import { TypebotIcon } from 'components/shared/TypebotHeader/TypebotIcon' import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { Plan } from 'db' import { Plan } from 'db'
@ -157,7 +157,7 @@ export const TypebotButton = ({
alignItems="center" alignItems="center"
fontSize={'4xl'} fontSize={'4xl'}
> >
{<TypebotIcon icon={typebot.icon} boxSize={'35px'} />} {<EmojiOrImageIcon icon={typebot.icon} boxSize={'35px'} />}
</Flex> </Flex>
<Text textAlign="center">{typebot.name}</Text> <Text textAlign="center">{typebot.name}</Text>
</VStack> </VStack>

View File

@ -24,6 +24,7 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null) const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
const confettiCanon = useRef<confetti.CreateTypes>() const confettiCanon = useRef<confetti.CreateTypes>()
const [chosenCategories, setChosenCategories] = useState<string[]>([]) const [chosenCategories, setChosenCategories] = useState<string[]>([])
const [openedOnce, setOpenedOnce] = useState(false)
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@ -37,12 +38,16 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (openedOnce) return
const isNewUser = const isNewUser =
user && user &&
new Date(user?.createdAt as unknown as string).toDateString() === new Date(user?.createdAt as unknown as string).toDateString() ===
new Date().toDateString() && new Date().toDateString() &&
totalTypebots === 0 totalTypebots === 0
if (isNewUser) onOpen() if (isNewUser) {
onOpen()
setOpenedOnce(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]) }, [user])

View File

@ -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 (
<Stack spacing="6">
<HStack>
<Text>Workspace subscription: </Text>
<PlanTag plan={workspace?.plan} />
</HStack>
{workspace?.stripeId && (
<>
<Text>
To manage your subscription and download invoices, head over to your
Stripe portal:
</Text>
<Button
as={NextChakraLink}
href={`/api/stripe/customer-portal?workspaceId=${workspace.id}`}
isExternal
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
>
Stripe Portal
</Button>
</>
)}
</Stack>
)
}
const PlanTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.TEAM: {
return <Tag colorScheme="purple">Team</Tag>
}
case Plan.LIFETIME:
case Plan.OFFERED:
case Plan.PRO: {
return <Tag colorScheme="orange">Personal Pro</Tag>
}
default: {
return <Tag colorScheme="gray">Free</Tag>
}
}
}

View File

@ -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>(
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 (
<HStack as="form" onSubmit={handleInvitationSubmit} pb="4">
<Input
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
isDisabled={isLocked}
/>
{!isLocked && (
<WorkspaceRoleMenuButton
role={invitationRole}
onChange={setInvitationRole}
/>
)}
<Button
colorScheme={'blue'}
isLoading={isSendingInvitation}
flexShrink={0}
type="submit"
isDisabled={isLoading || isLocked}
>
Invite
</Button>
</HStack>
)
}
const WorkspaceRoleMenuButton = ({
role,
onChange,
}: {
role: WorkspaceRole
onChange: (role: WorkspaceRole) => void
}) => {
return (
<Menu placement="bottom-end" isLazy matchWidth>
<MenuButton
flexShrink={0}
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
>
{convertWorkspaceRoleToReadable(role)}
</MenuButton>
<MenuList minW={0}>
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
<MenuItem onClick={() => onChange(WorkspaceRole.ADMIN)}>
{convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)}
</MenuItem>
<MenuItem onClick={() => onChange(WorkspaceRole.MEMBER)}>
{convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)}
</MenuItem>
</Stack>
</MenuList>
</Menu>
)
}
export const convertWorkspaceRoleToReadable = (role: WorkspaceRole) => {
switch (role) {
case WorkspaceRole.ADMIN:
return 'Admin'
case WorkspaceRole.MEMBER:
return 'Member'
}
}

View File

@ -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 (
<Menu placement="bottom-end" isLazy>
<MenuButton _hover={{ backgroundColor: 'gray.100' }} borderRadius="md">
<MemberIdentityContent
email={email}
name={name}
image={image}
isGuest={isGuest}
tag={convertWorkspaceRoleToReadable(role)}
/>
</MenuButton>
{!isMe && canEdit && (
<MenuList shadow="lg">
<MenuItem onClick={handleAdminClick}>
{convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)}
</MenuItem>
<MenuItem onClick={handleMemberClick}>
{convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)}
</MenuItem>
<MenuItem color="red.500" onClick={onDeleteClick}>
Remove
</MenuItem>
</MenuList>
)}
</Menu>
)
}
export const MemberIdentityContent = ({
name,
tag,
isGuest = false,
image,
email,
}: {
name?: string
tag?: string
image?: string
isGuest?: boolean
email: string
}) => (
<HStack justifyContent="space-between" maxW="full" p="2">
<HStack minW={0} spacing="4">
<Avatar name={name} src={image} size="sm" />
<Stack spacing={0} minW="0">
{name && (
<Text textAlign="left" fontSize="15px">
{name}
</Text>
)}
<Text
color="gray.500"
fontSize={name ? '14px' : 'inherit'}
noOfLines={0}
>
{email}
</Text>
</Stack>
</HStack>
<HStack flexShrink={0}>
{isGuest && (
<Tag color="gray.400" data-testid="tag">
Pending
</Tag>
)}
<Tag data-testid="tag">{tag}</Tag>
</HStack>
</HStack>
)

View File

@ -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 (
<Stack w="full">
{workspace?.plan !== Plan.TEAM && (
<UnlockPlanInfo
contentLabel={
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
}
plan={Plan.TEAM}
/>
)}
{workspace?.id && canEdit && (
<AddMemberForm
workspaceId={workspace.id}
onNewInvitation={handleNewInvitation}
onNewMember={handleNewMember}
isLoading={isLoading}
isLocked={workspace.plan !== Plan.TEAM}
/>
)}
{members?.map((member) => (
<MemberItem
key={member.userId}
email={member.email ?? ''}
image={member.image ?? undefined}
name={member.name ?? undefined}
role={member.role}
isMe={member.userId === user?.id}
onDeleteClick={handleDeleteMemberClick(member.userId)}
onSelectNewRole={handleSelectNewRole(member.userId)}
canEdit={canEdit}
/>
))}
{invitations?.map((invitation) => (
<MemberItem
key={invitation.email}
email={invitation.email ?? ''}
role={invitation.type}
onDeleteClick={handleDeleteInvitationClick(invitation.id)}
onSelectNewRole={handleSelectNewInvitationRole(invitation.id)}
isGuest
canEdit={canEdit}
/>
))}
{isLoading && (
<HStack py="4">
<SkeletonCircle boxSize="32px" />
<SkeletonText width="200px" noOfLines={2} />
</HStack>
)}
</Stack>
)
}

View File

@ -0,0 +1 @@
export { MembersList } from './MembersList'

View File

@ -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<HTMLInputElement>) => {
updateUser({ name: e.target.value })
}
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ email: e.target.value })
}
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
return (
<Stack spacing="6" w="full">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`public/users/${user?.id}/avatar`}
leftIcon={<UploadIcon />}
onFileUploaded={handleFileUploaded}
>
Change photo
</UploadButton>
<Text color="gray.500" fontSize="sm">
.jpg or.png, max 1MB
</Text>
</Stack>
</HStack>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
<Input id="name" value={user?.name ?? ''} onChange={handleNameChange} />
</FormControl>
{isDefined(user?.email) && (
<Tooltip
label="Updating email is not available."
placement="left"
hasArrow
>
<FormControl>
<FormLabel
htmlFor="email"
color={isOAuthProvider ? 'gray.500' : 'current'}
>
Email address
</FormLabel>
<Input
id="email"
type="email"
isDisabled
value={user?.email ?? ''}
onChange={handleEmailChange}
/>
</FormControl>
</Tooltip>
)}
<FormControl>
<FormLabel htmlFor="name">API token</FormLabel>
<InputGroup>
<Input
id="token"
value={user?.apiToken ?? ''}
type={isApiTokenVisible ? 'text' : 'password'}
/>
<InputRightElement mr="3">
<Button size="xs" onClick={toggleTokenVisibility}>
{isApiTokenVisible ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
</Stack>
)
}

View File

@ -12,8 +12,6 @@ import { useUser } from 'contexts/UserContext'
import { GraphNavigation } from 'db' import { GraphNavigation } from 'db'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
export const EditorSection = () => <EditorSettings />
export const EditorSettings = () => { export const EditorSettings = () => {
const { user, saveUser } = useUser() const { user, saveUser } = useUser()
const [value, setValue] = useState<string>( const [value, setValue] = useState<string>(
@ -44,7 +42,7 @@ export const EditorSettings = () => {
return ( return (
<Stack spacing={6}> <Stack spacing={6}>
<Heading size="md">Navigation</Heading> <Heading size="md">Editor Navigation</Heading>
<RadioGroup onChange={setValue} value={value}> <RadioGroup onChange={setValue} value={value}>
<HStack spacing={4} w="full" align="stretch"> <HStack spacing={4} w="full" align="stretch">
{options.map((option) => ( {options.map((option) => (

View File

@ -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 (
<Stack spacing="6" w="full">
<FormControl>
<FormLabel>Icon</FormLabel>
<Flex>
<EditableEmojiOrImageIcon
icon={workspace?.icon}
onChangeIcon={handleChangeIcon}
boxSize="40px"
/>
</Flex>
</FormControl>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
{workspace && (
<Input
id="name"
withVariableButton={false}
defaultValue={workspace?.name}
onChange={handleNameChange}
/>
)}
</FormControl>
</Stack>
)
}

View File

@ -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<SettingsTab>('my-account')
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent h="600px" flexDir="row">
<Stack spacing={8} w="250px" py="6" borderRightWidth={1} h="full">
<Stack>
<Text pl="4" color="gray.500">
{user.email}
</Text>
<Button
variant={selectedTab === 'my-account' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('my-account')}
leftIcon={
<Avatar
name={user.name ?? undefined}
src={user.image ?? undefined}
boxSize="15px"
/>
}
size="sm"
justifyContent="flex-start"
pl="4"
>
My account
</Button>
<Button
variant={selectedTab === 'user-settings' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('user-settings')}
leftIcon={<SettingsIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Preferences
</Button>
</Stack>
<Stack>
<Text pl="4" color="gray.500">
Workspace
</Text>
{canEdit && (
<Button
variant={
selectedTab === 'workspace-settings' ? 'solid' : 'ghost'
}
onClick={() => setSelectedTab('workspace-settings')}
leftIcon={
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="15px"
defaultIcon={HardDriveIcon}
/>
}
size="sm"
justifyContent="flex-start"
pl="4"
>
Settings
</Button>
)}
<Button
variant={selectedTab === 'members' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('members')}
leftIcon={<UsersIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Members
</Button>
{canEdit && (
<Button
variant={selectedTab === 'billing' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('billing')}
leftIcon={<CreditCardIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Billing
</Button>
)}
</Stack>
</Stack>
<Flex flex="1" p="10">
<SettingsContent tab={selectedTab} />
</Flex>
</ModalContent>
</Modal>
)
}
const SettingsContent = ({ tab }: { tab: SettingsTab }) => {
switch (tab) {
case 'my-account':
return <MyAccountForm />
case 'user-settings':
return <EditorSettings />
case 'workspace-settings':
return <WorkspaceSettingsForm />
case 'members':
return <MembersList />
case 'billing':
return <BillingForm />
default:
return null
}
}

View File

@ -0,0 +1 @@
export { WorkspaceSettingsModal } from './WorkspaceSettingsModal'

View File

@ -5,7 +5,7 @@ import {
ModalContent, ModalContent,
ModalOverlay, ModalOverlay,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { EditorSettings } from 'components/account/EditorSection' import { EditorSettings } from 'components/dashboard/WorkspaceSettingsModal/UserSettingsForm'
import React from 'react' import React from 'react'
type Props = { type Props = {

View File

@ -8,10 +8,10 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal' import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel' import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { useUser } from 'contexts/UserContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { GeneralSettings } from 'models' import { GeneralSettings } from 'models'
import React from 'react' import React from 'react'
import { isFreePlan } from 'services/user' import { isFreePlan } from 'services/workspace'
type Props = { type Props = {
generalSettings: GeneralSettings generalSettings: GeneralSettings
@ -23,8 +23,8 @@ export const GeneralSettingsForm = ({
onGeneralSettingsChange, onGeneralSettingsChange,
}: Props) => { }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { user } = useUser() const { workspace } = useWorkspace()
const isUserFreePlan = isFreePlan(user) const isUserFreePlan = isFreePlan(workspace)
const handleSwitchChange = () => { const handleSwitchChange = () => {
if (generalSettings?.isBrandingEnabled && isUserFreePlan) return if (generalSettings?.isBrandingEnabled && isUserFreePlan) return
onGeneralSettingsChange({ onGeneralSettingsChange({

View File

@ -13,16 +13,17 @@ import { TrashIcon } from 'assets/icons'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton' import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react' import React from 'react'
import { parseDefaultPublicId } from 'services/typebots' import { parseDefaultPublicId } from 'services/typebots'
import { isFreePlan } from 'services/user' import { isFreePlan } from 'services/workspace'
import { isDefined, isNotDefined } from 'utils' import { isDefined, isNotDefined } from 'utils'
import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown' import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown'
import { EditableUrl } from './EditableUrl' import { EditableUrl } from './EditableUrl'
import { integrationsList } from './integrations/EmbedButton' import { integrationsList } from './integrations/EmbedButton'
export const ShareContent = () => { export const ShareContent = () => {
const { user } = useUser() const { workspace } = useWorkspace()
const { typebot, updateOnBothTypebots } = useTypebot() const { typebot, updateOnBothTypebots } = useTypebot()
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@ -83,7 +84,7 @@ export const ShareContent = () => {
/> />
</HStack> </HStack>
)} )}
{isFreePlan(user) ? ( {isFreePlan(workspace) ? (
<UpgradeButton colorScheme="gray"> <UpgradeButton colorScheme="gray">
<Text mr="2">Add my domain</Text>{' '} <Text mr="2">Add my domain</Text>{' '}
<Tag colorScheme="orange">Pro</Tag> <Tag colorScheme="orange">Pro</Tag>

View File

@ -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])$/ /^(([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 = { type CustomDomainModalProps = {
userId: string workspaceId: string
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
domain?: string domain?: string
@ -31,7 +31,7 @@ type CustomDomainModalProps = {
} }
export const CustomDomainModal = ({ export const CustomDomainModal = ({
userId, workspaceId,
isOpen, isOpen,
onClose, onClose,
onNewDomain, onNewDomain,
@ -67,7 +67,7 @@ export const CustomDomainModal = ({
const onAddDomainClick = async () => { const onAddDomainClick = async () => {
if (!hostnameRegex.test(inputValue)) return if (!hostnameRegex.test(inputValue)) return
setIsLoading(true) setIsLoading(true)
const { error } = await createCustomDomain(userId, { const { error } = await createCustomDomain(workspaceId, {
name: inputValue, name: inputValue,
}) })
setIsLoading(false) setIsLoading(false)

View File

@ -16,6 +16,7 @@ import React, { useState } from 'react'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { CustomDomainModal } from './CustomDomainModal' import { CustomDomainModal } from './CustomDomainModal'
import { deleteCustomDomain, useCustomDomains } from 'services/user' import { deleteCustomDomain, useCustomDomains } from 'services/user'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = Omit<MenuButtonProps, 'type'> & { type Props = Omit<MenuButtonProps, 'type'> & {
currentCustomDomain?: string currentCustomDomain?: string
@ -29,26 +30,26 @@ export const CustomDomainsDropdown = ({
}: Props) => { }: Props) => {
const [isDeleting, setIsDeleting] = useState('') const [isDeleting, setIsDeleting] = useState('')
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { user } = useUser() const { workspace } = useWorkspace()
const { customDomains, mutate } = useCustomDomains({
userId: user?.id,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const { customDomains, mutate } = useCustomDomains({
workspaceId: workspace?.id,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const handleMenuItemClick = (customDomain: string) => () => const handleMenuItemClick = (customDomain: string) => () =>
onCustomDomainSelect(customDomain) onCustomDomainSelect(customDomain)
const handleDeleteDomainClick = const handleDeleteDomainClick =
(domainName: string) => async (e: React.MouseEvent) => { (domainName: string) => async (e: React.MouseEvent) => {
if (!user) return if (!workspace) return
e.stopPropagation() e.stopPropagation()
setIsDeleting(domainName) setIsDeleting(domainName)
const { error } = await deleteCustomDomain(user.id, domainName) const { error } = await deleteCustomDomain(workspace.id, domainName)
setIsDeleting('') setIsDeleting('')
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
mutate({ mutate({
@ -59,11 +60,11 @@ export const CustomDomainsDropdown = ({
} }
const handleNewDomain = (domain: string) => { const handleNewDomain = (domain: string) => {
if (!user) return if (!workspace) return
mutate({ mutate({
customDomains: [ customDomains: [
...(customDomains ?? []), ...(customDomains ?? []),
{ name: domain, ownerId: user?.id }, { name: domain, workspaceId: workspace?.id },
], ],
}) })
handleMenuItemClick(domain)() handleMenuItemClick(domain)()
@ -71,9 +72,9 @@ export const CustomDomainsDropdown = ({
return ( return (
<Menu isLazy placement="bottom-start" matchWidth> <Menu isLazy placement="bottom-start" matchWidth>
{user?.id && ( {workspace?.id && (
<CustomDomainModal <CustomDomainModal
userId={user.id} workspaceId={workspace.id}
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
onNewDomain={handleNewDomain} onNewDomain={handleNewDomain}

View File

@ -12,10 +12,10 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons' import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { CredentialsType } from 'models' import { CredentialsType } from 'models'
import { deleteCredentials, useCredentials } from 'services/user' import { deleteCredentials, useCredentials } from 'services/user'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = Omit<MenuButtonProps, 'type'> & { type Props = Omit<MenuButtonProps, 'type'> & {
type: CredentialsType type: CredentialsType
@ -36,13 +36,13 @@ export const CredentialsDropdown = ({
...props ...props
}: Props) => { }: Props) => {
const router = useRouter() const router = useRouter()
const { user } = useUser() const { workspace } = useWorkspace()
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const { credentials, mutate } = useCredentials({ const { credentials, mutate } = useCredentials({
userId: user?.id, workspaceId: workspace?.id,
}) })
const [isDeleting, setIsDeleting] = useState<string>() const [isDeleting, setIsDeleting] = useState<string>()
@ -84,9 +84,9 @@ export const CredentialsDropdown = ({
const handleDeleteDomainClick = const handleDeleteDomainClick =
(credentialsId: string) => async (e: React.MouseEvent) => { (credentialsId: string) => async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!user?.id) return if (!workspace?.id) return
setIsDeleting(credentialsId) setIsDeleting(credentialsId)
const { error } = await deleteCredentials(user?.id, credentialsId) const { error } = await deleteCredentials(workspace.id, credentialsId)
setIsDeleting(undefined) setIsDeleting(undefined)
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
onCredentialsSelect(undefined) onCredentialsSelect(undefined)

View File

@ -4,22 +4,31 @@ import {
chakra, chakra,
PopoverTrigger, PopoverTrigger,
PopoverContent, PopoverContent,
Flex,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import React from 'react' import React from 'react'
import { ImageUploadContent } from '../ImageUploadContent' import { EmojiOrImageIcon } from './EmojiOrImageIcon'
import { TypebotIcon } from './TypebotIcon' 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 ( return (
<Popover isLazy> <Popover isLazy>
{({ onClose }) => ( {({ onClose }) => (
<> <>
<Tooltip label="Change icon"> <Tooltip label="Change icon">
<chakra.span <Flex
cursor="pointer" cursor="pointer"
px="2" p="2"
rounded="md" rounded="md"
_hover={{ bgColor: 'gray.100' }} _hover={{ bgColor: 'gray.100' }}
transition="background-color 0.2s" transition="background-color 0.2s"
@ -27,10 +36,14 @@ export const EditableTypebotIcon = ({ icon, onChangeIcon }: Props) => {
> >
<PopoverTrigger> <PopoverTrigger>
<chakra.span> <chakra.span>
<TypebotIcon icon={icon} emojiFontSize="2xl" /> <EmojiOrImageIcon
icon={icon}
emojiFontSize="2xl"
boxSize={boxSize}
/>
</chakra.span> </chakra.span>
</PopoverTrigger> </PopoverTrigger>
</chakra.span> </Flex>
</Tooltip> </Tooltip>
<PopoverContent p="2"> <PopoverContent p="2">
<ImageUploadContent <ImageUploadContent

View File

@ -1,17 +1,19 @@
import { ToolIcon } from 'assets/icons' import { ToolIcon } from 'assets/icons'
import React from 'react' import React from 'react'
import { chakra, Image } from '@chakra-ui/react' import { chakra, IconProps, Image } from '@chakra-ui/react'
type Props = { type Props = {
icon?: string | null icon?: string | null
emojiFontSize?: string emojiFontSize?: string
boxSize?: string boxSize?: string
defaultIcon?: (props: IconProps) => JSX.Element
} }
export const TypebotIcon = ({ export const EmojiOrImageIcon = ({
icon, icon,
boxSize = '25px', boxSize = '25px',
emojiFontSize, emojiFontSize,
defaultIcon = ToolIcon,
}: Props) => { }: Props) => {
return ( return (
<> <>
@ -22,7 +24,7 @@ export const TypebotIcon = ({
boxSize={boxSize} boxSize={boxSize}
objectFit={icon.endsWith('.svg') ? undefined : 'cover'} objectFit={icon.endsWith('.svg') ? undefined : 'cover'}
alt="typebot icon" alt="typebot icon"
rounded="md" rounded="10%"
/> />
) : ( ) : (
<chakra.span role="img" fontSize={emojiFontSize}> <chakra.span role="img" fontSize={emojiFontSize}>
@ -30,7 +32,7 @@ export const TypebotIcon = ({
</chakra.span> </chakra.span>
) )
) : ( ) : (
<ToolIcon boxSize={boxSize} /> defaultIcon({ boxSize })
)} )}
</> </>
) )

View File

@ -1,7 +1,7 @@
import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react' import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { AnswersCount } from 'services/analytics' import { AnswersCount } from 'services/analytics'
import { import {
@ -9,7 +9,7 @@ import {
computeSourceCoordinates, computeSourceCoordinates,
computeDropOffPath, computeDropOffPath,
} from 'services/graph' } from 'services/graph'
import { isFreePlan } from 'services/user' import { isFreePlan } from 'services/workspace'
import { byId, isDefined } from 'utils' import { byId, isDefined } from 'utils'
type Props = { type Props = {
@ -23,11 +23,11 @@ export const DropOffEdge = ({
blockId, blockId,
onUnlockProPlanClick, onUnlockProPlanClick,
}: Props) => { }: Props) => {
const { user } = useUser() const { workspace } = useWorkspace()
const { sourceEndpoints, blocksCoordinates, graphPosition } = useGraph() const { sourceEndpoints, blocksCoordinates, graphPosition } = useGraph()
const { publishedTypebot } = useTypebot() const { publishedTypebot } = useTypebot()
const isUserOnFreePlan = isFreePlan(user) const isUserOnFreePlan = isFreePlan(workspace)
const totalAnswers = useMemo( const totalAnswers = useMemo(
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers, () => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,

View File

@ -15,6 +15,7 @@ import {
import { GoogleLogo } from 'assets/logos' import { GoogleLogo } from 'assets/logos'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { Info } from 'components/shared/Info' import { Info } from 'components/shared/Info'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react' import React from 'react'
import { getGoogleSheetsConsentScreenUrl } from 'services/integrations' import { getGoogleSheetsConsentScreenUrl } from 'services/integrations'
@ -25,6 +26,7 @@ type Props = {
} }
export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => { export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
const { workspace } = useWorkspace()
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="lg"> <Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay /> <ModalOverlay />
@ -54,7 +56,8 @@ export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
variant="outline" variant="outline"
href={getGoogleSheetsConsentScreenUrl( href={getGoogleSheetsConsentScreenUrl(
window.location.href, window.location.href,
stepId stepId,
workspace?.id
)} )}
mx="auto" mx="auto"
> >

View File

@ -1,10 +1,8 @@
import { Stack, useDisclosure, Text } from '@chakra-ui/react' import { Stack, useDisclosure, Text } from '@chakra-ui/react'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown' import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { Input, Textarea } from 'components/shared/Textbox' import { Input, Textarea } from 'components/shared/Textbox'
import { useTypebot } from 'contexts/TypebotContext'
import { CredentialsType, SendEmailOptions } from 'models' import { CredentialsType, SendEmailOptions } from 'models'
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { isDefined } from 'utils'
import { SmtpConfigModal } from './SmtpConfigModal' import { SmtpConfigModal } from './SmtpConfigModal'
type Props = { type Props = {
@ -13,16 +11,9 @@ type Props = {
} }
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => { export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { owner } = useTypebot()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0) 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) => { const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1) setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({ onOptionsChange({
@ -95,7 +86,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
<Input <Input
onChange={handleReplyToChange} onChange={handleReplyToChange}
defaultValue={options.replyTo} defaultValue={options.replyTo}
placeholder={owner?.email ?? 'email@gmail.com'} placeholder={'email@gmail.com'}
/> />
</Stack> </Stack>
<Stack> <Stack>

View File

@ -16,6 +16,7 @@ import { createCredentials } from 'services/user'
import { testSmtpConfig } from 'services/integrations' import { testSmtpConfig } from 'services/integrations'
import { isNotDefined } from 'utils' import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm' import { SmtpConfigForm } from './SmtpConfigForm'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = { type Props = {
isOpen: boolean isOpen: boolean
@ -29,6 +30,7 @@ export const SmtpConfigModal = ({
onClose, onClose,
}: Props) => { }: Props) => {
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@ -40,7 +42,7 @@ export const SmtpConfigModal = ({
}) })
const handleCreateClick = async () => { const handleCreateClick = async () => {
if (!user?.email) return if (!user?.email || !workspace?.id) return
setIsCreating(true) setIsCreating(true)
const { error: testSmtpError } = await testSmtpConfig( const { error: testSmtpError } = await testSmtpConfig(
smtpConfig, smtpConfig,
@ -53,10 +55,11 @@ export const SmtpConfigModal = ({
description: "We couldn't send the test email with your configuration", 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, data: smtpConfig,
name: smtpConfig.from.email as string, name: smtpConfig.from.email as string,
type: CredentialsType.SMTP, type: CredentialsType.SMTP,
workspaceId: workspace.id,
}) })
setIsCreating(false) setIsCreating(false)
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })

View File

@ -23,10 +23,13 @@ export const TypebotLinkSettingsForm = ({
return ( return (
<Stack> <Stack>
<TypebotsDropdown {typebot && (
typebotId={options.typebotId} <TypebotsDropdown
onSelectTypebotId={handleTypebotIdChange} typebotId={options.typebotId}
/> onSelectTypebotId={handleTypebotIdChange}
currentWorkspaceId={typebot.workspaceId as string}
/>
)}
<BlocksDropdown <BlocksDropdown
blocks={ blocks={
typebot && typebot &&

View File

@ -9,16 +9,22 @@ import { byId } from 'utils'
type Props = { type Props = {
typebotId?: string typebotId?: string
currentWorkspaceId: string
onSelectTypebotId: (typebotId: string | 'current') => void onSelectTypebotId: (typebotId: string | 'current') => void
} }
export const TypebotsDropdown = ({ typebotId, onSelectTypebotId }: Props) => { export const TypebotsDropdown = ({
typebotId,
onSelectTypebotId,
currentWorkspaceId,
}: Props) => {
const { query } = useRouter() const { query } = useRouter()
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const { typebots, isLoading } = useTypebots({ const { typebots, isLoading } = useTypebots({
workspaceId: currentWorkspaceId,
allFolders: true, allFolders: true,
onError: (e) => toast({ title: e.name, description: e.message }), onError: (e) => toast({ title: e.name, description: e.message }),
}) })

View File

@ -7,6 +7,7 @@ import {
Text, Text,
useDisclosure, useDisclosure,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { Plan } from 'db'
import React from 'react' import React from 'react'
import { UpgradeModal } from './modals/UpgradeModal' import { UpgradeModal } from './modals/UpgradeModal'
import { LimitReached } from './modals/UpgradeModal/UpgradeModal' import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
@ -22,14 +23,16 @@ export const PublishFirstInfo = (props: AlertProps) => (
<Info {...props}>You need to publish your typebot first</Info> <Info {...props}>You need to publish your typebot first</Info>
) )
export const UnlockProPlanInfo = ({ export const UnlockPlanInfo = ({
contentLabel, contentLabel,
buttonLabel, buttonLabel = 'More info',
type, type,
plan = Plan.PRO,
}: { }: {
contentLabel: string contentLabel: string
buttonLabel: string buttonLabel?: string
type?: LimitReached type?: LimitReached
plan: Plan
}) => { }) => {
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
return ( return (
@ -44,10 +47,10 @@ export const UnlockProPlanInfo = ({
<AlertIcon /> <AlertIcon />
<Text>{contentLabel}</Text> <Text>{contentLabel}</Text>
</HStack> </HStack>
<Button colorScheme="blue" onClick={onOpen}> <Button colorScheme="blue" onClick={onOpen} flexShrink={0} ml="2">
{buttonLabel} {buttonLabel}
</Button> </Button>
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} /> <UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
</Alert> </Alert>
) )
} }

View File

@ -0,0 +1,13 @@
import { Heading, Text, VStack } from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import React from 'react'
export const MaintenancePage = () => (
<VStack h="100vh" justify="center">
<TypebotLogo />
<Heading>
The tool is under maintenance for an exciting new feature! 🤩
</Heading>
<Text>Please come back again in 10 minutes.</Text>
</VStack>
)

View File

@ -1,13 +1,16 @@
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db' import { Plan } from 'db'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { isCloudProdInstance } from 'services/utils' import { isCloudProdInstance } from 'services/utils'
import { planToReadable } from 'services/workspace'
import { initBubble } from 'typebot-js' import { initBubble } from 'typebot-js'
export const SupportBubble = () => { export const SupportBubble = () => {
const { typebot } = useTypebot() const { typebot } = useTypebot()
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace()
const [localTypebotId, setLocalTypebotId] = useState(typebot?.id) const [localTypebotId, setLocalTypebotId] = useState(typebot?.id)
const [localUserId, setLocalUserId] = useState(user?.id) const [localUserId, setLocalUserId] = useState(user?.id)
@ -33,7 +36,7 @@ export const SupportBubble = () => {
Email: user?.email ?? undefined, Email: user?.email ?? undefined,
'Typebot ID': typebot?.id, 'Typebot ID': typebot?.id,
'Avatar URL': user?.image ?? undefined, 'Avatar URL': user?.image ?? undefined,
Plan: planToReadable(user?.plan), Plan: planToReadable(workspace?.plan),
}, },
}) })
} }
@ -42,17 +45,3 @@ export const SupportBubble = () => {
return <></> 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'
}
}

View File

@ -10,11 +10,15 @@ import {
MenuList, MenuList,
SkeletonCircle, SkeletonCircle,
SkeletonText, SkeletonText,
Text,
Tag,
Flex,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons' import { ChevronLeftIcon } from 'assets/icons'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { CollaborationType } from 'db' import { CollaborationType, WorkspaceRole } from 'db'
import React, { FormEvent, useState } from 'react' import React, { FormEvent, useState } from 'react'
import { import {
deleteCollaborator, deleteCollaborator,
@ -27,21 +31,19 @@ import {
deleteInvitation, deleteInvitation,
sendInvitation, sendInvitation,
} from 'services/typebots/invitations' } from 'services/typebots/invitations'
import { import { CollaboratorItem } from './CollaboratorButton'
CollaboratorIdentityContent,
CollaboratorItem,
} from './CollaboratorButton'
export const CollaborationList = () => { export const CollaborationList = () => {
const { user } = useUser() const { currentRole, workspace } = useWorkspace()
const { typebot, owner } = useTypebot() const { typebot } = useTypebot()
const [invitationType, setInvitationType] = useState<CollaborationType>( const [invitationType, setInvitationType] = useState<CollaborationType>(
CollaborationType.READ CollaborationType.READ
) )
const [invitationEmail, setInvitationEmail] = useState('') const [invitationEmail, setInvitationEmail] = useState('')
const [isSendingInvitation, setIsSendingInvitation] = useState(false) const [isSendingInvitation, setIsSendingInvitation] = useState(false)
const isOwner = user?.email === owner?.email const hasFullAccess =
(currentRole && currentRole !== WorkspaceRole.GUEST) || false
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@ -66,12 +68,12 @@ export const CollaborationList = () => {
} = useInvitations({ } = useInvitations({
typebotId: typebot?.id, typebotId: typebot?.id,
onError: (e) => onError: (e) =>
toast({ title: "Couldn't fetch collaborators", description: e.message }), toast({ title: "Couldn't fetch invitations", description: e.message }),
}) })
const handleChangeInvitationCollabType = const handleChangeInvitationCollabType =
(email: string) => async (type: CollaborationType) => { (email: string) => async (type: CollaborationType) => {
if (!typebot || !isOwner) return if (!typebot || !hasFullAccess) return
const { error } = await updateInvitation(typebot?.id, email, { const { error } = await updateInvitation(typebot?.id, email, {
email, email,
typebotId: typebot.id, typebotId: typebot.id,
@ -85,7 +87,7 @@ export const CollaborationList = () => {
}) })
} }
const handleDeleteInvitation = (email: string) => async () => { const handleDeleteInvitation = (email: string) => async () => {
if (!typebot || !isOwner) return if (!typebot || !hasFullAccess) return
const { error } = await deleteInvitation(typebot?.id, email) const { error } = await deleteInvitation(typebot?.id, email)
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
mutateInvitations({ mutateInvitations({
@ -95,7 +97,7 @@ export const CollaborationList = () => {
const handleChangeCollaborationType = const handleChangeCollaborationType =
(userId: string) => async (type: CollaborationType) => { (userId: string) => async (type: CollaborationType) => {
if (!typebot || !isOwner) return if (!typebot || !hasFullAccess) return
const { error } = await updateCollaborator(typebot?.id, userId, { const { error } = await updateCollaborator(typebot?.id, userId, {
userId, userId,
type, type,
@ -109,7 +111,7 @@ export const CollaborationList = () => {
}) })
} }
const handleDeleteCollaboration = (userId: string) => async () => { const handleDeleteCollaboration = (userId: string) => async () => {
if (!typebot || !isOwner) return if (!typebot || !hasFullAccess) return
const { error } = await deleteCollaborator(typebot?.id, userId) const { error } = await deleteCollaborator(typebot?.id, userId)
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
mutateCollaborators({ mutateCollaborators({
@ -119,7 +121,7 @@ export const CollaborationList = () => {
const handleInvitationSubmit = async (e: FormEvent) => { const handleInvitationSubmit = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
if (!typebot || !isOwner) return if (!typebot || !hasFullAccess) return
setIsSendingInvitation(true) setIsSendingInvitation(true)
const { error } = await sendInvitation(typebot.id, { const { error } = await sendInvitation(typebot.id, {
email: invitationEmail, email: invitationEmail,
@ -133,60 +135,57 @@ export const CollaborationList = () => {
setInvitationEmail('') setInvitationEmail('')
} }
const hasNobody =
(collaborators ?? []).length > 0 ||
((invitations ?? []).length > 0 &&
!isInvitationsLoading &&
!isCollaboratorsLoading)
return ( return (
<Stack spacing={2}> <Stack spacing={4} py="4">
{isOwner && ( <HStack as="form" onSubmit={handleInvitationSubmit} px="4">
<HStack <Input
as="form" size="sm"
onSubmit={handleInvitationSubmit} placeholder="colleague@company.com"
pt="4" name="inviteEmail"
px="4" value={invitationEmail}
pb={hasNobody ? '0' : '4'} onChange={(e) => setInvitationEmail(e.target.value)}
> rounded="md"
<Input isDisabled={!hasFullAccess}
size="sm" />
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
/>
{hasFullAccess && (
<CollaborationTypeMenuButton <CollaborationTypeMenuButton
type={invitationType} type={invitationType}
onChange={setInvitationType} onChange={setInvitationType}
/> />
<Button )}
size="sm" <Button
colorScheme="blue" size="sm"
isLoading={isSendingInvitation} colorScheme="blue"
flexShrink={0} isLoading={isSendingInvitation}
type="submit" flexShrink={0}
> type="submit"
Invite isDisabled={!hasFullAccess}
</Button> >
</HStack> Invite
)} </Button>
{owner && (collaborators ?? []).length > 0 && ( </HStack>
<CollaboratorIdentityContent {workspace && (
email={owner.email ?? ''} <Flex py="2" px="4" justifyContent="space-between">
name={owner.name ?? undefined} <HStack minW={0}>
image={owner.image ?? undefined} <EmojiOrImageIcon icon={workspace.icon} />
tag="Owner" <Text fontSize="15px" noOfLines={0}>
/> Everyone at {workspace.name}
</Text>
</HStack>
<Tag>
{convertCollaborationTypeEnumToReadable(
CollaborationType.FULL_ACCESS
)}
</Tag>
</Flex>
)} )}
{invitations?.map(({ email, type }) => ( {invitations?.map(({ email, type }) => (
<CollaboratorItem <CollaboratorItem
key={email} key={email}
email={email} email={email}
type={type} type={type}
isOwner={isOwner} isOwner={hasFullAccess}
onDeleteClick={handleDeleteInvitation(email)} onDeleteClick={handleDeleteInvitation(email)}
onChangeCollaborationType={handleChangeInvitationCollabType(email)} onChangeCollaborationType={handleChangeInvitationCollabType(email)}
isGuest isGuest
@ -199,7 +198,7 @@ export const CollaborationList = () => {
image={user.image ?? undefined} image={user.image ?? undefined}
name={user.name ?? undefined} name={user.name ?? undefined}
type={type} type={type}
isOwner={isOwner} isOwner={hasFullAccess}
onDeleteClick={handleDeleteCollaboration(userId ?? '')} onDeleteClick={handleDeleteCollaboration(userId ?? '')}
onChangeCollaborationType={handleChangeCollaborationType(userId)} onChangeCollaborationType={handleChangeCollaborationType(userId)}
/> />
@ -253,5 +252,7 @@ export const convertCollaborationTypeEnumToReadable = (
return 'Can view' return 'Can view'
case CollaborationType.WRITE: case CollaborationType.WRITE:
return 'Can edit' return 'Can edit'
case CollaborationType.FULL_ACCESS:
return 'Full access'
} }
} }

View File

@ -15,8 +15,8 @@ import { useRouter } from 'next/router'
import React from 'react' import React from 'react'
import { isNotDefined } from 'utils' import { isNotDefined } from 'utils'
import { PublishButton } from '../buttons/PublishButton' import { PublishButton } from '../buttons/PublishButton'
import { EditableEmojiOrImageIcon } from '../EditableEmojiOrImageIcon'
import { CollaborationMenuButton } from './CollaborationMenuButton' import { CollaborationMenuButton } from './CollaborationMenuButton'
import { EditableTypebotIcon } from './EditableTypebotIcons'
import { EditableTypebotName } from './EditableTypebotName' import { EditableTypebotName } from './EditableTypebotName'
export const headerHeight = 56 export const headerHeight = 56
@ -123,7 +123,7 @@ export const TypebotHeader = () => {
} }
/> />
<HStack spacing={1}> <HStack spacing={1}>
<EditableTypebotIcon <EditableEmojiOrImageIcon
icon={typebot?.icon} icon={typebot?.icon}
onChangeIcon={handleChangeIcon} onChangeIcon={handleChangeIcon}
/> />

View File

@ -1,20 +1,28 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Alert, Heading,
AlertIcon,
Modal, Modal,
ModalBody, ModalBody,
ModalCloseButton, Text,
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader,
ModalOverlay, ModalOverlay,
Stack, Stack,
ListItem,
UnorderedList,
ListIcon,
chakra,
Tooltip,
ListProps,
Button,
HStack,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { PricingCard } from './PricingCard'
import { ActionButton } from './ActionButton'
import { pay } from 'services/stripe' import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext' 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 { export enum LimitReached {
BRAND = 'Remove branding', BRAND = 'Remove branding',
@ -26,10 +34,16 @@ type UpgradeModalProps = {
type?: LimitReached type?: LimitReached
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
plan?: Plan
} }
export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => { export const UpgradeModal = ({
onClose,
isOpen,
plan = Plan.PRO,
}: UpgradeModalProps) => {
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace()
const [payLoading, setPayLoading] = useState(false) const [payLoading, setPayLoading] = useState(false)
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd') 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 () => { const handlePayClick = async () => {
if (!user) return if (!user || !workspace) return
setPayLoading(true) setPayLoading(true)
await pay(user, currency) await pay({
user,
currency,
plan: plan === Plan.TEAM ? 'team' : 'pro',
workspaceId: workspace.id,
})
} }
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="xl"> <Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>Upgrade to Pro plan</ModalHeader> <ModalBody as={Stack} pt="10">
<ModalCloseButton /> {plan === Plan.PRO ? (
<ModalBody as={Stack} spacing={6} alignItems="center"> <PersonalProPlanContent currency={currency} />
{limitLabel && ( ) : (
<Alert status="warning" rounded="md"> <TeamPlanContent currency={currency} />
<AlertIcon />
{limitLabel}
</Alert>
)} )}
<PricingCard </ModalBody>
data={{
price: currency === 'eur' ? '25€' : '$30', <ModalFooter>
name: 'Pro plan', <HStack>
features: [ <Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handlePayClick}
isLoading={payLoading}
colorScheme="blue"
>
Upgrade
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const PersonalProPlanContent = ({ currency }: { currency: 'eur' | 'usd' }) => {
return (
<Stack spacing="4">
<TypebotLogo boxSize="30px" />
<Heading fontSize="2xl">
Upgrade to <chakra.span color="orange.400">Personal Pro</chakra.span>{' '}
plan
</Heading>
<Text>For solo creator who want to do even more.</Text>
<Heading>
{currency === 'eur' ? '39€' : '$39'}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">Everything in Personal, plus:</Text>
<FeatureList
features={[
'Branding removed',
'View incomplete submissions',
'In-depth drop off analytics',
'Unlimited custom domains',
'Organize typebots in folders',
'Unlimited uploads',
]}
/>
</Stack>
)
}
const TeamPlanContent = ({ currency }: { currency: 'eur' | 'usd' }) => {
return (
<Stack spacing="4">
<TypebotLogo boxSize="30px" />
<Heading fontSize="2xl">
Upgrade to <chakra.span color="purple.400">Team</chakra.span> plan
</Heading>
<Text>For teams to build typebots together in one spot.</Text>
<Heading>
{currency === 'eur' ? '99€' : '$99'}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeatureList
features={[
'Branding removed', 'Branding removed',
'View incomplete submissions', 'View incomplete submissions',
'In-depth drop off analytics', 'In-depth drop off analytics',
'Custom domains', 'Custom domains',
'Organize typebots in folders', 'Organize typebots in folders',
'Unlimited uploads', 'Unlimited uploads',
'Custom Google Analytics events', ]}
], spacing="0"
}} />
button={ }
<ActionButton onClick={handlePayClick} isLoading={payLoading}> hasArrow
Upgrade now placement="top"
</ActionButton> >
} <chakra.span textDecoration="underline" cursor="pointer">
/> Everything in Pro
</ModalBody> </chakra.span>
</Tooltip>
<ModalFooter /> , plus:
</ModalContent> </Text>
</Modal> <FeatureList
features={[
'Unlimited team members',
'Collaborative workspace',
'Custom roles',
]}
/>
</Stack>
) )
} }
const FeatureList = ({
features,
...props
}: { features: string[] } & ListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat) => (
<ListItem key={feat}>
<ListIcon as={CheckIcon} />
{feat}
</ListItem>
))}
</UnorderedList>
)

View File

@ -8,6 +8,7 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ToolIcon, TemplateIcon, DownloadIcon } from 'assets/icons' import { ToolIcon, TemplateIcon, DownloadIcon } from 'assets/icons'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Typebot } from 'models' import { Typebot } from 'models'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import React, { useState } from 'react' import React, { useState } from 'react'
@ -16,6 +17,7 @@ import { ImportTypebotFromFileButton } from './ImportTypebotFromFileButton'
import { TemplatesModal } from './TemplatesModal' import { TemplatesModal } from './TemplatesModal'
export const CreateNewTypebotButtons = () => { export const CreateNewTypebotButtons = () => {
const { workspace } = useWorkspace()
const { user } = useUser() const { user } = useUser()
const router = useRouter() const router = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
@ -29,15 +31,16 @@ export const CreateNewTypebotButtons = () => {
}) })
const handleCreateSubmit = async (typebot?: Typebot) => { const handleCreateSubmit = async (typebot?: Typebot) => {
if (!user) return if (!user || !workspace) return
setIsLoading(true) setIsLoading(true)
const folderId = router.query.folderId?.toString() ?? null const folderId = router.query.folderId?.toString() ?? null
const { error, data } = typebot const { error, data } = typebot
? await importTypebot( ? await importTypebot(
{ {
...typebot, ...typebot,
ownerId: user.id,
folderId, folderId,
workspaceId: workspace.id,
ownerId: user.id,
theme: { theme: {
...typebot.theme, ...typebot.theme,
chat: { chat: {
@ -46,10 +49,11 @@ export const CreateNewTypebotButtons = () => {
}, },
}, },
}, },
user.plan workspace.plan
) )
: await createTypebot({ : await createTypebot({
folderId, folderId,
workspaceId: workspace.id,
}) })
if (error) toast({ description: error.message }) if (error) toast({ description: error.message })
if (data) if (data)

View File

@ -40,7 +40,6 @@ import useUndo from 'services/utils/useUndo'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
import { itemsAction, ItemsActions } from './actions/items' import { itemsAction, ItemsActions } from './actions/items'
import { dequal } from 'dequal' import { dequal } from 'dequal'
import { User } from 'db'
import { saveWebhook } from 'services/webhook' import { saveWebhook } from 'services/webhook'
import { stringify } from 'qs' import { stringify } from 'qs'
import cuid from 'cuid' import cuid from 'cuid'
@ -63,7 +62,6 @@ const typebotContext = createContext<
typebot?: Typebot typebot?: Typebot
publishedTypebot?: PublicTypebot publishedTypebot?: PublicTypebot
linkedTypebots?: Typebot[] linkedTypebots?: Typebot[]
owner?: User
webhooks: Webhook[] webhooks: Webhook[]
isReadOnly?: boolean isReadOnly?: boolean
isPublished: boolean isPublished: boolean
@ -108,22 +106,15 @@ export const TypebotContext = ({
status: 'error', status: 'error',
}) })
const { const { typebot, publishedTypebot, webhooks, isReadOnly, isLoading, mutate } =
typebot, useFetchedTypebot({
publishedTypebot, typebotId,
owner, onError: (error) =>
webhooks, toast({
isReadOnly, title: 'Error while fetching typebot',
isLoading, description: error.message,
mutate, }),
} = useFetchedTypebot({ })
typebotId,
onError: (error) =>
toast({
title: 'Error while fetching typebot',
description: error.message,
}),
})
const [ const [
{ present: localTypebot }, { present: localTypebot },
@ -150,6 +141,7 @@ export const TypebotContext = ({
) )
const { typebots: linkedTypebots } = useLinkedTypebots({ const { typebots: linkedTypebots } = useLinkedTypebots({
workspaceId: localTypebot?.workspaceId ?? undefined,
typebotId, typebotId,
typebotIds: linkedTypebotIds, typebotIds: linkedTypebotIds,
onError: (error) => onError: (error) =>
@ -373,7 +365,6 @@ export const TypebotContext = ({
typebot: localTypebot, typebot: localTypebot,
publishedTypebot, publishedTypebot,
linkedTypebots, linkedTypebots,
owner,
webhooks: webhooks ?? [], webhooks: webhooks ?? [],
isReadOnly, isReadOnly,
isSavingLoading, isSavingLoading,
@ -415,17 +406,17 @@ export const useFetchedTypebot = ({
typebot: Typebot typebot: Typebot
webhooks: Webhook[] webhooks: Webhook[]
publishedTypebot?: PublicTypebot publishedTypebot?: PublicTypebot
owner?: User
isReadOnly?: boolean isReadOnly?: boolean
}, },
Error Error
>(`/api/typebots/${typebotId}`, fetcher, { dedupingInterval: 0 }) >(`/api/typebots/${typebotId}`, fetcher, {
dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
})
if (error) onError(error) if (error) onError(error)
return { return {
typebot: data?.typebot, typebot: data?.typebot,
webhooks: data?.webhooks, webhooks: data?.webhooks,
publishedTypebot: data?.publishedTypebot, publishedTypebot: data?.publishedTypebot,
owner: data?.owner,
isReadOnly: data?.isReadOnly, isReadOnly: data?.isReadOnly,
isLoading: !error && !data, isLoading: !error && !data,
mutate, mutate,
@ -433,15 +424,17 @@ export const useFetchedTypebot = ({
} }
const useLinkedTypebots = ({ const useLinkedTypebots = ({
workspaceId,
typebotId, typebotId,
typebotIds, typebotIds,
onError, onError,
}: { }: {
workspaceId?: string
typebotId?: string typebotId?: string
typebotIds?: string[] typebotIds?: string[]
onError: (error: Error) => void onError: (error: Error) => void
}) => { }) => {
const params = stringify({ typebotIds }, { indices: false }) const params = stringify({ typebotIds, workspaceId }, { indices: false })
const { data, error, mutate } = useSWR< const { data, error, mutate } = useSWR<
{ {
typebots: Typebot[] typebots: Typebot[]

View File

@ -21,6 +21,7 @@ const userContext = createContext<{
isSaving: boolean isSaving: boolean
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
isOAuthProvider: boolean isOAuthProvider: boolean
currentWorkspaceId?: string
updateUser: (newUser: Partial<User>) => void updateUser: (newUser: Partial<User>) => void
saveUser: (newUser?: Partial<User>) => Promise<void> saveUser: (newUser?: Partial<User>) => Promise<void>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -35,6 +36,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>()
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const isOAuthProvider = useMemo( const isOAuthProvider = useMemo(
@ -49,6 +51,9 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
useEffect(() => { useEffect(() => {
if (isDefined(user) || isNotDefined(session)) return if (isDefined(user) || isNotDefined(session)) return
setCurrentWorkspaceId(
localStorage.getItem('currentWorkspaceId') ?? undefined
)
const parsedUser = session.user as User const parsedUser = session.user as User
setUser(parsedUser) setUser(parsedUser)
if (parsedUser?.id) setSentryUser({ id: parsedUser.id }) if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
@ -96,6 +101,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
isLoading: status === 'loading', isLoading: status === 'loading',
hasUnsavedChanges, hasUnsavedChanges,
isOAuthProvider, isOAuthProvider,
currentWorkspaceId,
updateUser, updateUser,
saveUser, saveUser,
}} }}

View File

@ -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<void>
updateWorkspace: (
workspaceId: string,
updates: Partial<Workspace>
) => Promise<void>
// 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<WorkspaceWithMembers>()
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<Workspace>
) => {
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 (
<workspaceContext.Provider
value={{
workspaces,
workspace: currentWorkspace,
isLoading,
canEdit,
currentRole,
switchWorkspace,
createWorkspace,
updateWorkspace,
}}
>
{children}
</workspaceContext.Provider>
)
}
export const useWorkspace = () => useContext(workspaceContext)

View File

@ -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 (
<Flex h="full" w="full" justifyContent="center" align="flex-start" pb="20">
<Stack maxW="600px" w="full" pt="4" spacing={10}>
<Flex>
<Button
as={NextChakraLink}
variant="outline"
size="sm"
leftIcon={<ChevronLeftIcon />}
href="/typebots"
>
Back
</Button>
</Flex>
<Heading as="h1" fontSize="3xl">
Account Settings
</Heading>
<Divider />
<PersonalInfoForm />
<Divider />
<BillingSection />
<Divider />
<EditorSection />
</Stack>
</Flex>
)
}

View File

@ -1,17 +1,17 @@
import { Button, Flex, HStack, Tag, useToast, Text } from '@chakra-ui/react' import { Button, Flex, HStack, Tag, useToast, Text } from '@chakra-ui/react'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useUser } from 'contexts/UserContext' import { useWorkspace } from 'contexts/WorkspaceContext'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { useStats } from 'services/analytics' import { useStats } from 'services/analytics'
import { isFreePlan } from 'services/user/user' import { isFreePlan } from 'services/workspace'
import { AnalyticsContent } from './AnalyticsContent' import { AnalyticsContent } from './AnalyticsContent'
import { SubmissionsContent } from './SubmissionContent' import { SubmissionsContent } from './SubmissionContent'
export const ResultsContent = () => { export const ResultsContent = () => {
const router = useRouter() const router = useRouter()
const { user } = useUser() const { workspace } = useWorkspace()
const { typebot, publishedTypebot } = useTypebot() const { typebot, publishedTypebot } = useTypebot()
const isAnalytics = useMemo( const isAnalytics = useMemo(
() => router.pathname.endsWith('analytics'), () => router.pathname.endsWith('analytics'),
@ -81,7 +81,7 @@ export const ResultsContent = () => {
onDeleteResults={handleDeletedResults} onDeleteResults={handleDeletedResults}
totalResults={stats?.totalStarts ?? 0} totalResults={stats?.totalStarts ?? 0}
totalHiddenResults={ totalHiddenResults={
isFreePlan(user) isFreePlan(workspace)
? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0) ? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0)
: undefined : undefined
} }

View File

@ -10,10 +10,11 @@ import {
useResults, useResults,
} from 'services/typebots' } from 'services/typebots'
import { unparse } from 'papaparse' import { unparse } from 'papaparse'
import { UnlockProPlanInfo } from 'components/shared/Info' import { UnlockPlanInfo } from 'components/shared/Info'
import { LogsModal } from './LogsModal' import { LogsModal } from './LogsModal'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { isDefined, parseResultHeader } from 'utils' import { isDefined, parseResultHeader } from 'utils'
import { Plan } from 'db'
type Props = { type Props = {
typebotId: string typebotId: string
@ -147,9 +148,10 @@ export const SubmissionsContent = ({
return ( return (
<Stack maxW="1200px" w="full" pb="28" px={['4', '0']} spacing="4"> <Stack maxW="1200px" w="full" pb="28" px={['4', '0']} spacing="4">
{totalHiddenResults && ( {totalHiddenResults && (
<UnlockProPlanInfo <UnlockPlanInfo
buttonLabel={`Unlock ${totalHiddenResults} results`} buttonLabel={`Unlock ${totalHiddenResults} results`}
contentLabel="You are seeing complete submissions only." contentLabel="You are seeing complete submissions only."
plan={Plan.PRO}
/> />
)} )}
{publishedTypebot && ( {publishedTypebot && (

View File

@ -17,9 +17,9 @@ export const getAuthenticatedGoogleClient = async (
{ client: OAuth2Client; credentials: CredentialsFromDb } | undefined { client: OAuth2Client; credentials: CredentialsFromDb } | undefined
> => { > => {
const credentials = (await prisma.credentials.findFirst({ const credentials = (await prisma.credentials.findFirst({
where: { id: credentialsId, ownerId: userId }, where: { id: credentialsId, workspace: { members: { some: { userId } } } },
})) as CredentialsFromDb | undefined })) as CredentialsFromDb | undefined
if (!credentials || credentials.ownerId !== userId) return if (!credentials) return
const data = decrypt( const data = decrypt(
credentials.data, credentials.data,
credentials.iv credentials.iv

View File

@ -17,6 +17,8 @@ import { KBarProvider } from 'kbar'
import { actions } from 'libs/kbar' import { actions } from 'libs/kbar'
import { enableMocks } from 'mocks' import { enableMocks } from 'mocks'
import { SupportBubble } from 'components/shared/SupportBubble' 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() if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks()
@ -31,27 +33,30 @@ const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => {
}, [pathname]) }, [pathname])
const typebotId = query.typebotId?.toString() const typebotId = query.typebotId?.toString()
return ( return <MaintenancePage />
<ChakraProvider theme={customTheme}> // return (
<KBarProvider actions={actions}> // <ChakraProvider theme={customTheme}>
<SessionProvider session={session}> // <KBarProvider actions={actions}>
<UserContext> // <SessionProvider session={session}>
{typebotId ? ( // <UserContext>
<TypebotContext typebotId={typebotId}> // {typebotId ? (
<Component /> // <TypebotContext typebotId={typebotId}>
<SupportBubble /> // <WorkspaceContext>
</TypebotContext> // <Component />
) : ( // <SupportBubble />
<> // </WorkspaceContext>
<Component {...pageProps} /> // </TypebotContext>
<SupportBubble /> // ) : (
</> // <WorkspaceContext>
)} // <Component {...pageProps} />
</UserContext> // <SupportBubble />
</SessionProvider> // </WorkspaceContext>
</KBarProvider> // )}
</ChakraProvider> // </UserContext>
) // </SessionProvider>
// </KBarProvider>
// </ChakraProvider>
// )
} }
export default App export default App

View File

@ -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 = () => (
<Stack>
<Seo title="My account" />
<AccountHeader />
<AccountContent />
</Stack>
)
export default AccountSubscriptionPage

View File

@ -1,16 +1,33 @@
// Forked from https://github.com/nextauthjs/adapters/blob/main/packages/prisma/src/index.ts // 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 { randomUUID } from 'crypto'
import type { Adapter, AdapterUser } from 'next-auth/adapters' import type { Adapter, AdapterUser } from 'next-auth/adapters'
import cuid from 'cuid' import cuid from 'cuid'
import { got } from 'got' import { got } from 'got'
type InvitationWithWorkspaceId = Invitation & {
typebot: {
workspaceId: string | null
}
}
export function CustomAdapter(p: PrismaClient): Adapter { export function CustomAdapter(p: PrismaClient): Adapter {
return { return {
createUser: async (data: Omit<AdapterUser, 'id'>) => { createUser: async (data: Omit<AdapterUser, 'id'>) => {
const user = { id: cuid(), email: data.email as string } const user = { id: cuid(), email: data.email as string }
const invitations = await p.invitation.findMany({ const invitations = await p.invitation.findMany({
where: { email: user.email }, 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({ const createdUser = await p.user.create({
data: { data: {
@ -18,6 +35,25 @@ export function CustomAdapter(p: PrismaClient): Adapter {
id: user.id, id: user.id,
apiToken: randomUUID(), apiToken: randomUUID(),
plan: process.env.ADMIN_EMAIL === data.email ? Plan.PRO : Plan.FREE, 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) if (process.env.USER_CREATED_WEBHOOK_URL)
@ -29,6 +65,8 @@ export function CustomAdapter(p: PrismaClient): Adapter {
}) })
if (invitations.length > 0) if (invitations.length > 0)
await convertInvitationsToCollaborations(p, user, invitations) await convertInvitationsToCollaborations(p, user, invitations)
if (workspaceInvitations.length > 0)
await joinWorkspaces(p, user, workspaceInvitations)
return createdUser return createdUser
}, },
getUser: (id) => p.user.findUnique({ where: { id } }), 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_secret: data.oauth_token_secret as string,
oauth_token: data.oauth_token as string, oauth_token: data.oauth_token as string,
refresh_token_expires_in: data.refresh_token_expires_in as number, refresh_token_expires_in: data.refresh_token_expires_in as number,
} },
}) as any }) as any
}, },
unlinkAccount: (provider_providerAccountId) => unlinkAccount: (provider_providerAccountId) =>
@ -94,7 +132,7 @@ export function CustomAdapter(p: PrismaClient): Adapter {
const convertInvitationsToCollaborations = async ( const convertInvitationsToCollaborations = async (
p: PrismaClient, p: PrismaClient,
{ id, email }: { id: string; email: string }, { id, email }: { id: string; email: string },
invitations: Invitation[] invitations: InvitationWithWorkspaceId[]
) => { ) => {
await p.collaboratorsOnTypebots.createMany({ await p.collaboratorsOnTypebots.createMany({
data: invitations.map((invitation) => ({ data: invitations.map((invitation) => ({
@ -103,9 +141,54 @@ const convertInvitationsToCollaborations = async (
userId: id, userId: id,
})), })),
}) })
const workspaceInvitations = invitations.reduce<InvitationWithWorkspaceId[]>(
(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({ return p.invitation.deleteMany({
where: { where: {
email, 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,
},
})
}

View File

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

View File

@ -1,35 +1,48 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { Prisma } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { Credentials } from 'models' import { Credentials } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const id = req.query.id.toString() const workspaceId = req.query.workspaceId as string | undefined
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) if (!workspaceId) return badRequest(res)
if (req.method === 'GET') { if (req.method === 'GET') {
const credentials = await prisma.credentials.findMany({ const credentials = await prisma.credentials.findMany({
where: { ownerId: user.id }, where: {
select: { name: true, type: true, ownerId: true, id: true }, 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 }) return res.send({ credentials })
} }
if (req.method === 'POST') { if (req.method === 'POST') {
const data = ( const data = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Omit<Credentials, 'ownerId'> ) as Credentials
const { encryptedData, iv } = encrypt(data.data) 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({ const credentials = await prisma.credentials.create({
data: { data: {
...data, ...data,
data: encryptedData, data: encryptedData,
iv, iv,
ownerId: user.id, workspaceId,
} as Prisma.CredentialsUncheckedCreateInput, },
}) })
return res.send({ credentials }) return res.send({ credentials })
} }

View File

@ -2,17 +2,20 @@ import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const id = req.query.id.toString() const workspaceId = req.query.workspaceId as string | undefined
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) if (!workspaceId) return badRequest(res)
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
const credentialsId = req.query.credentialsId.toString() const credentialsId = req.query.credentialsId.toString()
const credentials = await prisma.credentials.delete({ const credentials = await prisma.credentials.deleteMany({
where: { id: credentialsId }, where: {
id: credentialsId,
workspace: { id: workspaceId, members: { some: { userId: user.id } } },
},
}) })
return res.send({ credentials }) return res.send({ credentials })
} }

View File

@ -4,7 +4,7 @@ import prisma from 'libs/prisma'
import { googleSheetsScopes } from './consent-url' import { googleSheetsScopes } from './consent-url'
import { stringify } from 'querystring' import { stringify } from 'querystring'
import { CredentialsType } from 'models' import { CredentialsType } from 'models'
import { encrypt, notAuthenticated } from 'utils' import { badRequest, encrypt, notAuthenticated } from 'utils'
import { oauth2Client } from 'libs/google-sheets' import { oauth2Client } from 'libs/google-sheets'
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { getAuthenticatedUser } from 'services/api/utils' import { getAuthenticatedUser } from 'services/api/utils'
@ -12,11 +12,12 @@ import { getAuthenticatedUser } from 'services/api/utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const { redirectUrl, stepId } = JSON.parse( const { redirectUrl, stepId, workspaceId } = JSON.parse(
Buffer.from(req.query.state.toString(), 'base64').toString() Buffer.from(req.query.state.toString(), 'base64').toString()
) )
if (req.method === 'GET') { if (req.method === 'GET') {
const code = req.query.code.toString() const code = req.query.code.toString()
if (!workspaceId) return badRequest(res)
if (!code) if (!code)
return res.status(400).send({ message: "Bad request, couldn't get code" }) return res.status(400).send({ message: "Bad request, couldn't get code" })
const { tokens } = await oauth2Client.getToken(code) const { tokens } = await oauth2Client.getToken(code)
@ -41,20 +42,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const credentials = { const credentials = {
name: email, name: email,
type: CredentialsType.GOOGLE_SHEETS, type: CredentialsType.GOOGLE_SHEETS,
ownerId: user.id, workspaceId,
data: encryptedData, data: encryptedData,
iv, iv,
} as Prisma.CredentialsUncheckedCreateInput } as Prisma.CredentialsUncheckedCreateInput
const { id: credentialsId } = await prisma.credentials.upsert({ const { id: credentialsId } = await prisma.credentials.create({
create: credentials, data: credentials,
update: credentials,
where: {
name_type_ownerId: {
name: credentials.name,
type: credentials.type,
ownerId: user.id,
},
},
}) })
const queryParams = stringify({ stepId, credentialsId }) const queryParams = stringify({ stepId, credentialsId })
res.redirect( res.redirect(

View File

@ -1,26 +1,38 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { CustomDomain, Prisma } from 'db' import { CustomDomain } from 'db'
import { got, HTTPError } from 'got' import { got, HTTPError } from 'got'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const id = req.query.id.toString() const workspaceId = req.query.workspaceId as string | undefined
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) if (!workspaceId) return badRequest(res)
if (req.method === 'GET') { if (req.method === 'GET') {
const customDomains = await prisma.customDomain.findMany({ const customDomains = await prisma.customDomain.findMany({
where: { ownerId: user.id }, where: {
workspace: { id: workspaceId, members: { some: { userId: user.id } } },
},
}) })
return res.send({ customDomains }) return res.send({ customDomains })
} }
if (req.method === 'POST') { 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 = ( const data = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Omit<CustomDomain, 'ownerId'> ) as CustomDomain
try { try {
await createDomainOnVercel(data.name) await createDomainOnVercel(data.name)
} catch (err) { } catch (err) {
@ -31,8 +43,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const customDomains = await prisma.customDomain.create({ const customDomains = await prisma.customDomain.create({
data: { data: {
...data, ...data,
ownerId: user.id, workspaceId,
} as Prisma.CustomDomainUncheckedCreateInput, },
}) })
return res.send({ customDomains }) return res.send({ customDomains })
} }

View File

@ -1,15 +1,15 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed, notAuthenticated } from 'utils' import { badRequest, methodNotAllowed, notAuthenticated } from 'utils'
import { got } from 'got' import { got } from 'got'
import { getAuthenticatedUser } from 'services/api/utils' import { getAuthenticatedUser } from 'services/api/utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const id = req.query.id.toString() const workspaceId = req.query.workspaceId as string | undefined
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' }) if (!workspaceId) return badRequest(res)
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
const domain = req.query.domain.toString() const domain = req.query.domain.toString()
try { try {
@ -18,6 +18,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const customDomains = await prisma.customDomain.delete({ const customDomains = await prisma.customDomain.delete({
where: { name: domain }, where: { name: domain },
}) })
console.log(
{
name: domain,
workspace: { id: workspaceId },
},
{ some: { userId: user.id } }
)
await deleteDomainOnVercel(domain)
return res.send({ customDomains }) return res.send({ customDomains })
} }
return methodNotAllowed(res) return methodNotAllowed(res)

View File

@ -3,7 +3,7 @@ import { DashboardFolder } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
@ -12,11 +12,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const parentFolderId = req.query.parentId const parentFolderId = req.query.parentId
? req.query.parentId.toString() ? req.query.parentId.toString()
: null : null
if (req.method === 'GET') { if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string | undefined
if (!workspaceId) return badRequest(res)
const folders = await prisma.dashboardFolder.findMany({ const folders = await prisma.dashboardFolder.findMany({
where: { where: {
ownerId: user.id,
parentFolderId, parentFolderId,
workspace: { members: { some: { userId: user.id, workspaceId } } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
@ -25,9 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') { if (req.method === 'POST') {
const data = ( const data = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Pick<DashboardFolder, 'parentFolderId'> ) as Pick<DashboardFolder, 'parentFolderId' | 'workspaceId'>
const folder = await prisma.dashboardFolder.create({ const folder = await prisma.dashboardFolder.create({
data: { ...data, ownerId: user.id, name: 'New folder' }, data: { ...data, name: 'New folder' },
}) })
return res.send(folder) return res.send(folder)
} }

View File

@ -11,14 +11,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const id = req.query.id.toString() const id = req.query.id.toString()
if (req.method === 'GET') { if (req.method === 'GET') {
const folder = await prisma.dashboardFolder.findUnique({ const folder = await prisma.dashboardFolder.findFirst({
where: { id_ownerId: { id, ownerId: user.id } }, where: {
id,
workspace: { members: { some: { userId: user.id } } },
},
}) })
return res.send({ folder }) return res.send({ folder })
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
const folders = await prisma.dashboardFolder.delete({ const folders = await prisma.dashboardFolder.deleteMany({
where: { id_ownerId: { id, ownerId: user.id } }, where: { id, workspace: { members: { some: { userId: user.id } } } },
}) })
return res.send({ folders }) return res.send({ folders })
} }
@ -26,8 +29,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const data = ( const data = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Partial<DashboardFolder> ) as Partial<DashboardFolder>
const folders = await prisma.dashboardFolder.update({ const folders = await prisma.dashboardFolder.updateMany({
where: { id_ownerId: { id, ownerId: user.id } }, where: {
id,
workspace: { members: { some: { userId: user.id } } },
},
data, data,
}) })
return res.send({ typebots: folders }) return res.send({ typebots: folders })

View File

@ -18,7 +18,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') { if (req.method === 'GET') {
const credentialsId = req.query.credentialsId as string | undefined const credentialsId = req.query.credentialsId as string | undefined
if (!credentialsId) return badRequest(res) if (!credentialsId) return badRequest(res)
const spreadsheetId = req.query.id.toString() const spreadsheetId = req.query.id as string
const doc = new GoogleSpreadsheet(spreadsheetId) const doc = new GoogleSpreadsheet(spreadsheetId)
const auth = await getAuthenticatedGoogleClient(user.id, credentialsId) const auth = await getAuthenticatedGoogleClient(user.id, credentialsId)
if (!auth) if (!auth)

View File

@ -10,8 +10,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
}) })
const { email, currency } = const { email, currency, plan, workspaceId } =
typeof req.body === 'string' ? JSON.parse(req.body) : req.body typeof req.body === 'string' ? JSON.parse(req.body) : req.body
console.log(plan, workspaceId)
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
success_url: `${req.headers.origin}/typebots?stripe=success`, success_url: `${req.headers.origin}/typebots?stripe=success`,
cancel_url: `${req.headers.origin}/typebots?stripe=cancel`, cancel_url: `${req.headers.origin}/typebots?stripe=cancel`,
@ -19,12 +21,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
allow_promotion_codes: true, allow_promotion_codes: true,
customer_email: email, customer_email: email,
mode: 'subscription', mode: 'subscription',
metadata: { workspaceId, plan },
line_items: [ line_items: [
{ {
price: price: getPrice(plan, currency),
currency === 'eur'
? process.env.STRIPE_PRICE_EUR_ID
: process.env.STRIPE_PRICE_USD_ID,
quantity: 1, quantity: 1,
}, },
], ],
@ -34,4 +34,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res) 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) export default withSentry(handler)

View File

@ -1,25 +1,36 @@
import { User } from 'db'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react' import {
import { methodNotAllowed } from 'utils' badRequest,
forbidden,
methodNotAllowed,
notAuthenticated,
} from 'utils'
import Stripe from 'stripe' import Stripe from 'stripe'
import { withSentry } from '@sentry/nextjs' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req }) const user = await getAuthenticatedUser(req)
if (!session?.user) if (!user) return notAuthenticated(res)
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' })
if (req.method === 'GET') { if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string | undefined
if (!workspaceId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY) if (!process.env.STRIPE_SECRET_KEY)
throw Error('STRIPE_SECRET_KEY var is missing') 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, { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2020-08-27', apiVersion: '2020-08-27',
}) })
const session = await stripe.billingPortal.sessions.create({ const session = await stripe.billingPortal.sessions.create({
customer: user.stripeId, customer: workspace.stripeId,
return_url: req.headers.referer, return_url: req.headers.referer,
}) })
res.redirect(session.url) res.redirect(session.url)

View File

@ -40,36 +40,44 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (event.type) { switch (event.type) {
case 'checkout.session.completed': { case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session const session = event.data.object as Stripe.Checkout.Session
const { customer_email } = session const { metadata } = session
if (!customer_email) if (!metadata?.workspaceId || !metadata?.plan)
return res.status(500).send(`customer_email not found`) return res.status(500).send({ message: `customer_email not found` })
await prisma.user.update({ await prisma.workspace.update({
where: { email: customer_email }, where: { id: metadata.workspaceId },
data: { plan: Plan.PRO, stripeId: session.customer as string }, 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': { case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription 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: { where: {
stripeId: subscription.customer as string, id: metadata.workspaceId,
}, },
data: { data: {
plan: Plan.FREE, plan: Plan.FREE,
}, },
}) })
return res.status(200).send({ message: 'user downgraded in DB' }) return res.send({ message: 'workspace downgraded in DB' })
} }
default: { default: {
return res.status(304).send({ message: 'event not handled' }) return res.status(304).send({ message: 'event not handled' })
} }
} }
} catch (err) { } catch (err) {
console.error(err)
if (err instanceof Error) { if (err instanceof Error) {
console.error(err) console.error(err)
return res.status(400).send(`Webhook Error: ${err.message}`) return res.status(400).send(`Webhook Error: ${err.message}`)
} }
return res.status(500).send(`Error occured: ${err}`)
} }
} }
return methodNotAllowed(res) return methodNotAllowed(res)

View File

@ -1,28 +1,30 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { Prisma } from 'db' import { Prisma, WorkspaceRole } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils' import { getAuthenticatedUser } from 'services/api/utils'
import { parseNewTypebot } from 'services/typebots/typebots' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
try { try {
if (req.method === 'GET') { if (req.method === 'GET') {
const workspaceId = req.query.workspaceId as string | undefined
const folderId = req.query.allFolders const folderId = req.query.allFolders
? undefined ? undefined
: req.query.folderId : req.query.folderId
? req.query.folderId.toString() ? req.query.folderId.toString()
: null : null
if (!workspaceId) return badRequest(res)
const typebotIds = req.query.typebotIds as string[] | undefined const typebotIds = req.query.typebotIds as string[] | undefined
if (typebotIds) { if (typebotIds) {
const typebots = await prisma.typebot.findMany({ const typebots = await prisma.typebot.findMany({
where: { where: {
OR: [ OR: [
{ {
ownerId: user.id, workspace: { members: { some: { userId: user.id } } },
id: { in: typebotIds }, id: { in: typebotIds },
}, },
{ {
@ -42,8 +44,29 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
} }
const typebots = await prisma.typebot.findMany({ const typebots = await prisma.typebot.findMany({
where: { where: {
ownerId: user.id, OR: [
folderId, {
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' }, orderBy: { createdAt: 'desc' },
select: { name: true, publishedTypebotId: true, id: true, icon: true }, select: { name: true, publishedTypebotId: true, id: true, icon: true },

View File

@ -16,26 +16,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
where: canReadTypebot(typebotId, user), where: canReadTypebot(typebotId, user),
include: { include: {
publishedTypebot: true, publishedTypebot: true,
owner: { select: { email: true, name: true, image: true } },
collaborators: { select: { userId: true, type: true } }, collaborators: { select: { userId: true, type: true } },
webhooks: true, webhooks: true,
}, },
}) })
if (!typebot) return res.send({ typebot: null }) if (!typebot) return res.send({ typebot: null })
const { const { publishedTypebot, collaborators, webhooks, ...restOfTypebot } =
publishedTypebot, typebot
owner,
collaborators,
webhooks,
...restOfTypebot
} = typebot
const isReadOnly = const isReadOnly =
collaborators.find((c) => c.userId === user.id)?.type === collaborators.find((c) => c.userId === user.id)?.type ===
CollaborationType.READ CollaborationType.READ
return res.send({ return res.send({
typebot: restOfTypebot, typebot: restOfTypebot,
publishedTypebot, publishedTypebot,
owner,
isReadOnly, isReadOnly,
webhooks, webhooks,
}) })

View File

@ -1,33 +1,28 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { canWriteTypebot } from 'services/api/dbRules' import { canEditGuests } from 'services/api/dbRules'
import { getAuthenticatedUser } from 'services/api/utils' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const typebotId = req.query.typebotId as string const typebotId = req.query.typebotId as string
const userId = req.query.userId as string const userId = req.query.userId as string
const typebot = await prisma.typebot.findFirst({ if (req.method === 'PATCH') {
where: canWriteTypebot(typebotId, user),
})
if (!typebot) return forbidden(res)
if (req.method === 'PUT') {
const data = req.body const data = req.body
await prisma.collaboratorsOnTypebots.upsert({ await prisma.collaboratorsOnTypebots.updateMany({
where: { userId_typebotId: { typebotId, userId } }, where: { userId, typebot: canEditGuests(user, typebotId) },
create: data, data: { type: data.type },
update: data,
}) })
return res.send({ return res.send({
message: 'success', message: 'success',
}) })
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
await prisma.collaboratorsOnTypebots.delete({ await prisma.collaboratorsOnTypebots.deleteMany({
where: { userId_typebotId: { typebotId, userId } }, where: { userId, typebot: canEditGuests(user, typebotId) },
}) })
return res.send({ return res.send({
message: 'success', message: 'success',

View File

@ -1,6 +1,6 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { invitationToCollaborate } from 'assets/emails/invitationToCollaborate' import { invitationToCollaborate } from 'assets/emails/invitationToCollaborate'
import { CollaborationType } from 'db' import { CollaborationType, WorkspaceRole } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules' import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
@ -30,7 +30,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const typebot = await prisma.typebot.findFirst({ const typebot = await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user), where: canWriteTypebot(typebotId, user),
}) })
if (!typebot) return forbidden(res) if (!typebot || !typebot.workspaceId) return forbidden(res)
const { email, type } = const { email, type } =
(req.body as (req.body as
| { email: string | undefined; type: CollaborationType | undefined } | { email: string | undefined; type: CollaborationType | undefined }
@ -40,7 +40,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { email: email.toLowerCase() }, where: { email: email.toLowerCase() },
select: { id: true }, select: { id: true },
}) })
if (existingUser) if (existingUser) {
await prisma.collaboratorsOnTypebots.create({ await prisma.collaboratorsOnTypebots.create({
data: { data: {
type, type,
@ -48,7 +48,21 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: existingUser.id, 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({ await prisma.invitation.create({
data: { email: email.toLowerCase(), type, typebotId }, data: { email: email.toLowerCase(), type, typebotId },
}) })

View File

@ -1,33 +1,32 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { Invitation } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { canWriteTypebot } from 'services/api/dbRules' import { canEditGuests } from 'services/api/dbRules'
import { getAuthenticatedUser } from 'services/api/utils' 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 handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const typebotId = req.query.typebotId as string const typebotId = req.query.typebotId as string
const email = req.query.email as string const email = req.query.email as string
const typebot = await prisma.typebot.findFirst({ if (req.method === 'PATCH') {
where: canWriteTypebot(typebotId, user), const data = req.body as Invitation
}) await prisma.invitation.updateMany({
if (!typebot) return forbidden(res) where: { email, typebot: canEditGuests(user, typebotId) },
if (req.method === 'PUT') { data: { type: data.type },
const data = req.body
await prisma.invitation.upsert({
where: { email_typebotId: { email, typebotId } },
create: data,
update: data,
}) })
return res.send({ return res.send({
message: 'success', message: 'success',
}) })
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
await prisma.invitation.delete({ await prisma.invitation.deleteMany({
where: { email_typebotId: { email, typebotId } }, where: {
email,
typebot: canEditGuests(user, typebotId),
},
}) })
return res.send({ return res.send({
message: 'success', message: 'success',

View File

@ -3,13 +3,19 @@ import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules' import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
import { getAuthenticatedUser } from 'services/api/utils' import { getAuthenticatedUser } from 'services/api/utils'
import { isFreePlan } from 'services/user/user' import { isFreePlan } from 'services/workspace'
import { methodNotAllowed, notAuthenticated } from 'utils' import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req) const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
const workspaceId = req.query.workspaceId as string | undefined
if (req.method === 'GET') { 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 typebotId = req.query.typebotId.toString()
const lastResultId = req.query.lastResultId?.toString() const lastResultId = req.query.lastResultId?.toString()
const take = parseInt(req.query.limit?.toString()) const take = parseInt(req.query.limit?.toString())
@ -24,7 +30,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { where: {
typebot: canReadTypebot(typebotId, user), typebot: canReadTypebot(typebotId, user),
answers: { some: {} }, answers: { some: {} },
isCompleted: isFreePlan(user) ? true : undefined, isCompleted: isFreePlan(workspace) ? true : undefined,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

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

View File

@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
id: webhookId, id: webhookId,
typebot: { typebot: {
OR: [ OR: [
{ ownerId: user.id }, { workspace: { members: { some: { userId: user.id } } } },
{ {
collaborators: { collaborators: {
some: { some: {
@ -40,7 +40,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const typebot = await prisma.typebot.findFirst({ const typebot = await prisma.typebot.findFirst({
where: { where: {
OR: [ OR: [
{ id: data.typebotId, ownerId: user.id }, {
id: data.typebotId,
workspace: { members: { some: { userId: user.id } } },
},
{ {
collaborators: { collaborators: {
some: { some: {

View File

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

View File

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

View File

@ -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<WorkspaceInvitation, 'id' | 'createdAt'>
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)

View File

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

View File

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

View File

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

View File

@ -10,26 +10,32 @@ import { Spinner, useToast } from '@chakra-ui/react'
import { pay } from 'services/stripe' import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { NextPageContext } from 'next/types' import { NextPageContext } from 'next/types'
import { useWorkspace } from 'contexts/WorkspaceContext'
const DashboardPage = () => { const DashboardPage = () => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { query, isReady } = useRouter() const { query, isReady } = useRouter()
const { user } = useUser() const { user } = useUser()
const { workspace } = useWorkspace()
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
status: 'success', status: 'success',
}) })
useEffect(() => { useEffect(() => {
const subscribe = query.subscribe?.toString() const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
if (subscribe && user && user.plan === 'FREE') { if (workspace && subscribePlan && user && user.plan === 'FREE') {
setIsLoading(true) setIsLoading(true)
pay( pay({
user, 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(() => { useEffect(() => {
if (!isReady) return if (!isReady) return
@ -38,7 +44,7 @@ const DashboardPage = () => {
if (stripeStatus === 'success') if (stripeStatus === 'success')
toast({ toast({
title: 'Typebot Pro', title: 'Payment successful',
description: "You've successfully subscribed 🎉", description: "You've successfully subscribed 🎉",
}) })
if (couponCode) { if (couponCode) {

View File

@ -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 (
<Stack minH="100vh">
<Seo title="My typebots" />
<DashboardHeader />
<Flex w="full" flex="1" justify="center">
<Stack w="1000px" spacing={6}>
<Heading as="h1">Shared with me</Heading>
<Stack>
<Flex>
<BackButton id={null} />
</Flex>
<Wrap spacing={4}>
{isLoading && <ButtonSkeleton />}
{sharedTypebots?.map((typebot) => (
<TypebotButton key={typebot.id} typebot={typebot} isReadOnly />
))}
</Wrap>
</Stack>
</Stack>
</Flex>
</Stack>
)
}
export default SharedTypebotsPage

View File

@ -11,18 +11,36 @@ import {
CollaborationType, CollaborationType,
DashboardFolder, DashboardFolder,
GraphNavigation, GraphNavigation,
Plan,
PrismaClient, PrismaClient,
User, User,
WorkspaceRole,
} from 'db' } from 'db'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { encrypt } from 'utils' import { encrypt } from 'utils'
const prisma = new PrismaClient() const prisma = new PrismaClient()
const proWorkspaceId = 'proWorkspace'
export const freeWorkspaceId = 'freeWorkspace'
export const sharedWorkspaceId = 'sharedWorkspace'
export const guestWorkspaceId = 'guestWorkspace'
export const teardownDatabase = async () => { export const teardownDatabase = async () => {
const ownerFilter = { 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({ await prisma.user.deleteMany({
where: { id: { in: ['freeUser', 'proUser'] } }, where: { id: { in: ['freeUser', 'proUser'] } },
}) })
@ -37,23 +55,75 @@ export const setupDatabase = async () => {
return createCredentials() return createCredentials()
} }
export const createUsers = () => export const createUsers = async () => {
prisma.user.createMany({ await prisma.user.create({
data: [ data: {
{ id: 'proUser',
id: 'freeUser', email: 'pro-user@email.com',
email: 'free-user@email.com', name: 'Pro user',
name: 'Free user', graphNavigation: GraphNavigation.TRACKPAD,
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 ( export const createWebhook = async (
typebotId: string, typebotId: string,
@ -91,7 +161,7 @@ export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
export const createFolders = (partialFolders: Partial<DashboardFolder>[]) => export const createFolders = (partialFolders: Partial<DashboardFolder>[]) =>
prisma.dashboardFolder.createMany({ prisma.dashboardFolder.createMany({
data: partialFolders.map((folder) => ({ data: partialFolders.map((folder) => ({
ownerId: 'proUser', workspaceId: proWorkspaceId,
name: 'Folder #1', name: 'Folder #1',
...folder, ...folder,
})), })),
@ -110,9 +180,9 @@ const createCredentials = () => {
data: [ data: [
{ {
name: 'pro-user@email.com', name: 'pro-user@email.com',
ownerId: 'proUser',
type: CredentialsType.GOOGLE_SHEETS, type: CredentialsType.GOOGLE_SHEETS,
data: encryptedData, data: encryptedData,
workspaceId: proWorkspaceId,
iv, iv,
}, },
], ],
@ -179,9 +249,10 @@ const parseTypebotToPublicTypebot = (
const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
id: partialTypebot.id ?? 'typebot', id: partialTypebot.id ?? 'typebot',
ownerId: 'proUser',
workspaceId: proWorkspaceId,
folderId: null, folderId: null,
name: 'My typebot', name: 'My typebot',
ownerId: 'proUser',
theme: defaultTheme, theme: defaultTheme,
settings: defaultSettings, settings: defaultSettings,
publicId: null, publicId: null,
@ -243,8 +314,9 @@ export const importTypebotInDatabase = async (
) => { ) => {
const typebot: any = { const typebot: any = {
...JSON.parse(readFileSync(path).toString()), ...JSON.parse(readFileSync(path).toString()),
...updates, workspaceId: proWorkspaceId,
ownerId: 'proUser', ownerId: 'proUser',
...updates,
} }
await prisma.typebot.create({ await prisma.typebot.create({
data: typebot, data: typebot,

View File

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

View File

@ -1,35 +1,41 @@
import test, { expect } from '@playwright/test' import test, { expect } from '@playwright/test'
import cuid from 'cuid' import cuid from 'cuid'
import { CollaborationType, Plan, WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import { InputStepType, defaultTextInputOptions } from 'models' import { InputStepType, defaultTextInputOptions } from 'models'
import path from 'path'
import { import {
createResults, createResults,
createTypebots, createTypebots,
parseDefaultBlockWithStep, parseDefaultBlockWithStep,
} from '../services/database' } 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.describe('Typebot owner', () => {
test.use({
storageState: path.join(__dirname, '../freeUser.json'),
})
test('Can invite collaborators', async ({ page }) => { 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.goto(`/typebots/${typebotId}/edit`)
await page.click('button[aria-label="Show collaboration menu"]') await page.click('button[aria-label="Show collaboration menu"]')
await expect(page.locator('text=Free user')).toBeHidden() 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 expect(page.locator('text=Free user')).toBeHidden()
await page.fill( await page.fill(
'input[placeholder="colleague@company.com"]', 'input[placeholder="colleague@company.com"]',
'pro-user@email.com' 'free-user@email.com'
) )
await page.click('text=Can edit') await page.click('text=Can edit')
await page.click('text=Can view') await page.click('text=Can view')
await page.click('text=Invite') await page.click('text=Invite')
await expect(page.locator('text=Free user')).toBeVisible() 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="guest@email.com"')
await page.click('text="Remove"') await page.click('text="Remove"')
await expect(page.locator('text="guest@email.com"')).toBeHidden() await expect(page.locator('text="guest@email.com"')).toBeHidden()
@ -59,17 +64,47 @@ test.describe('Typebot owner', () => {
test.describe('Collaborator', () => { test.describe('Collaborator', () => {
test('should display shared typebots', async ({ page }) => { test('should display shared typebots', async ({ page }) => {
await page.goto('/typebots') const typebotId = cuid()
await expect(page.locator('text=Shared')).toBeVisible() const guestWorkspaceId = cuid()
await page.click('text=Shared') await prisma.workspace.create({
await page.waitForNavigation() data: {
expect(page.url()).toMatch('/typebots/shared') id: guestWorkspaceId,
await expect(page.locator('text="Shared typebot"')).toBeVisible() name: 'Guest Workspace #2',
await page.click('text=Shared typebot') 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('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="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 page.click('text=Block #1', { force: true })
await expect(page.locator('input[value="Block #1"]')).toBeHidden() await expect(page.locator('input[value="Block #1"]')).toBeHidden()
await page.goto(`/typebots/${typebotId}/results`) await page.goto(`/typebots/${typebotId}/results`)

View File

@ -1,63 +1,67 @@
import test, { expect } from '@playwright/test' import test, { expect } from '@playwright/test'
import { InputStepType, defaultTextInputOptions } from 'models' import { InputStepType, defaultTextInputOptions } from 'models'
import { createTypebots, parseDefaultBlockWithStep } from '../services/database' import {
createTypebots,
freeWorkspaceId,
parseDefaultBlockWithStep,
} from '../services/database'
import path from 'path' import path from 'path'
import cuid from 'cuid' import cuid from 'cuid'
const typebotId = cuid() test('should be able to connect custom domain', async ({ page }) => {
test.describe('Dashboard page', () => { const typebotId = cuid()
test('should be able to connect custom domain', async ({ page }) => { 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([ await createTypebots([
{ {
id: typebotId, id: typebotId,
workspaceId: freeWorkspaceId,
...parseDefaultBlockWithStep({ ...parseDefaultBlockWithStep({
type: InputStepType.TEXT, type: InputStepType.TEXT,
options: defaultTextInputOptions, options: defaultTextInputOptions,
}), }),
}, },
]) ])
await page.goto(`/typebots/${typebotId}/share`) await page.goto(`/typebots/${typebotId}/share`)
await page.click('text=Add my domain') await page.click('text=Add my domain')
await page.click('text=Connect new') await expect(page.locator('text=For solo creator')).toBeVisible()
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()
})
}) })
}) })

View File

@ -80,11 +80,10 @@ test.describe('Dashboard page', () => {
}) })
test("create folder shouldn't be available", async ({ page }) => { test("create folder shouldn't be available", async ({ page }) => {
await page.goto('/typebots') await page.goto('/typebots')
await page.click('text=Shared workspace')
await page.click('text=Free workspace')
await page.click('text=Create a folder') await page.click('text=Create a folder')
await expect( await expect(page.locator('text=For solo creator')).toBeVisible()
page.locator('text="You can\'t create folders with the basic plan"')
).toBeVisible()
await expect(page.locator('text=Upgrade now')).toBeVisible()
}) })
}) })
}) })

View File

@ -1,6 +1,7 @@
import test, { expect, Page } from '@playwright/test' import test, { expect, Page } from '@playwright/test'
import cuid from 'cuid' import cuid from 'cuid'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import prisma from 'libs/prisma'
import { defaultTextInputOptions, InputStepType } from 'models' import { defaultTextInputOptions, InputStepType } from 'models'
import { parse } from 'papaparse' import { parse } from 'papaparse'
import path from 'path' import path from 'path'
@ -113,14 +114,18 @@ test.describe('Results page', () => {
validateExportAll(dataAll) validateExportAll(dataAll)
}) })
test.describe('Free user', () => { test.describe('Free user', async () => {
test.use({ test.use({
storageState: path.join(__dirname, '../freeUser.json'), storageState: path.join(__dirname, '../freeUser.json'),
}) })
test("Incomplete results shouldn't be displayed", async ({ page }) => { 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.goto(`/typebots/${typebotId}/results`)
await page.click('text=Unlock') await page.click('text=Unlock')
await expect(page.locator('text=Upgrade now')).toBeVisible() await expect(page.locator('text=For solo creator')).toBeVisible()
}) })
}) })
}) })

View File

@ -124,13 +124,14 @@ test.describe.parallel('Settings page', () => {
path.join(__dirname, '../fixtures/typebots/settings.json'), path.join(__dirname, '../fixtures/typebots/settings.json'),
{ {
id: typebotId, id: typebotId,
workspaceId: 'free',
} }
) )
await page.goto(`/typebots/${typebotId}/settings`) await page.goto(`/typebots/${typebotId}/settings`)
await page.click('button:has-text("General")') await page.click('button:has-text("General")')
await expect(page.locator('text=Pro')).toBeVisible() await expect(page.locator('text=Pro')).toBeVisible()
await page.click('text=Typebot.io branding') await page.click('text=Typebot.io branding')
await expect(page.locator('text=Upgrade now')).toBeVisible() await expect(page.locator('text=For solo creator')).toBeVisible()
}) })
}) })
}) })

View File

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

View File

@ -1,4 +1,4 @@
import { CollaborationType, Prisma, User } from 'db' import { CollaborationType, Prisma, User, WorkspaceRole } from 'db'
const parseWhereFilter = ( const parseWhereFilter = (
typebotIds: string[] | string, typebotIds: string[] | string,
@ -6,14 +6,6 @@ const parseWhereFilter = (
type: 'read' | 'write' type: 'read' | 'write'
): Prisma.TypebotWhereInput => ({ ): Prisma.TypebotWhereInput => ({
OR: [ 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 }, id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
collaborators: { 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) => export const canWriteTypebots = (typebotIds: string[], user: User) =>
parseWhereFilter(typebotIds, user, 'write') parseWhereFilter(typebotIds, user, 'write')
export const canEditGuests = (user: User, typebotId: string) => ({
id: typebotId,
workspace: {
members: {
some: { userId: user.id, role: { not: WorkspaceRole.GUEST } },
},
},
})

View File

@ -1,17 +1,18 @@
import { Credentials } from 'models' import { Credentials } from 'models'
import { stringify } from 'qs'
import useSWR from 'swr' import useSWR from 'swr'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
import { fetcher } from '../utils' import { fetcher } from './utils'
export const useCredentials = ({ export const useCredentials = ({
userId, workspaceId,
onError, onError,
}: { }: {
userId?: string workspaceId?: string
onError?: (error: Error) => void onError?: (error: Error) => void
}) => { }) => {
const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>( const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>(
userId ? `/api/users/${userId}/credentials` : null, workspaceId ? `/api/credentials?${stringify({ workspaceId })}` : null,
fetcher fetcher
) )
if (error && onError) onError(error) if (error && onError) onError(error)
@ -23,24 +24,25 @@ export const useCredentials = ({
} }
export const createCredentials = async ( export const createCredentials = async (
userId: string, credentials: Omit<Credentials, 'id' | 'iv' | 'createdAt' | 'ownerId'>
credentials: Omit<Credentials, 'ownerId' | 'id' | 'iv' | 'createdAt'>
) => ) =>
sendRequest<{ sendRequest<{
credentials: Credentials credentials: Credentials
}>({ }>({
url: `/api/users/${userId}/credentials`, url: `/api/credentials?${stringify({
workspaceId: credentials.workspaceId,
})}`,
method: 'POST', method: 'POST',
body: credentials, body: credentials,
}) })
export const deleteCredentials = async ( export const deleteCredentials = async (
userId: string, workspaceId: string,
credentialsId: string credentialsId: string
) => ) =>
sendRequest<{ sendRequest<{
credentials: Credentials credentials: Credentials
}>({ }>({
url: `/api/users/${userId}/credentials/${credentialsId}`, url: `/api/credentials/${credentialsId}?${stringify({ workspaceId })}`,
method: 'DELETE', method: 'DELETE',
}) })

View File

@ -1,20 +1,24 @@
import { CustomDomain } from 'db' import { CustomDomain } from 'db'
import { Credentials } from 'models' import { Credentials } from 'models'
import { stringify } from 'qs'
import useSWR from 'swr' import useSWR from 'swr'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
import { fetcher } from '../utils' import { fetcher } from './utils'
export const useCustomDomains = ({ export const useCustomDomains = ({
userId, workspaceId,
onError, onError,
}: { }: {
userId?: string workspaceId?: string
onError: (error: Error) => void onError: (error: Error) => void
}) => { }) => {
const { data, error, mutate } = useSWR< const { data, error, mutate } = useSWR<
{ customDomains: Omit<CustomDomain, 'createdAt'>[] }, { customDomains: Omit<CustomDomain, 'createdAt' | 'ownerId'>[] },
Error Error
>(userId ? `/api/users/${userId}/customDomains` : null, fetcher) >(
workspaceId ? `/api/customDomains?${stringify({ workspaceId })}` : null,
fetcher
)
if (error) onError(error) if (error) onError(error)
return { return {
customDomains: data?.customDomains, customDomains: data?.customDomains,
@ -24,24 +28,24 @@ export const useCustomDomains = ({
} }
export const createCustomDomain = async ( export const createCustomDomain = async (
userId: string, workspaceId: string,
customDomain: Omit<CustomDomain, 'ownerId' | 'createdAt'> customDomain: Omit<CustomDomain, 'createdAt' | 'workspaceId' | 'ownerId'>
) => ) =>
sendRequest<{ sendRequest<{
credentials: Credentials credentials: Credentials
}>({ }>({
url: `/api/users/${userId}/customDomains`, url: `/api/customDomains?${stringify({ workspaceId })}`,
method: 'POST', method: 'POST',
body: customDomain, body: customDomain,
}) })
export const deleteCustomDomain = async ( export const deleteCustomDomain = async (
userId: string, workspaceId: string,
customDomain: string customDomain: string
) => ) =>
sendRequest<{ sendRequest<{
credentials: Credentials credentials: Credentials
}>({ }>({
url: `/api/users/${userId}/customDomains/${customDomain}`, url: `/api/customDomains/${customDomain}?${stringify({ workspaceId })}`,
method: 'DELETE', method: 'DELETE',
}) })

View File

@ -6,14 +6,16 @@ import { sendRequest } from 'utils'
export const useFolders = ({ export const useFolders = ({
parentId, parentId,
workspaceId,
onError, onError,
}: { }: {
workspaceId?: string
parentId?: string parentId?: string
onError: (error: Error) => void onError: (error: Error) => void
}) => { }) => {
const params = stringify({ parentId }) const params = stringify({ parentId, workspaceId })
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>( const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
`/api/folders?${params}`, workspaceId ? `/api/folders?${params}` : null,
fetcher, fetcher,
{ dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined } { dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined }
) )
@ -45,12 +47,13 @@ export const useFolderContent = ({
} }
export const createFolder = async ( export const createFolder = async (
workspaceId: string,
folder: Pick<DashboardFolder, 'parentFolderId'> folder: Pick<DashboardFolder, 'parentFolderId'>
) => ) =>
sendRequest<DashboardFolder>({ sendRequest<DashboardFolder>({
url: `/api/folders`, url: `/api/folders`,
method: 'POST', method: 'POST',
body: folder, body: { ...folder, workspaceId },
}) })
export const deleteFolder = async (id: string) => export const deleteFolder = async (id: string) =>

View File

@ -11,9 +11,10 @@ import {
export const getGoogleSheetsConsentScreenUrl = ( export const getGoogleSheetsConsentScreenUrl = (
redirectUrl: string, 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}` return `/api/credentials/google-sheets/consent-url?${queryParams}`
} }

View File

@ -38,6 +38,7 @@ export const parsePublicTypebotToTypebot = (
folderId: existingTypebot.folderId, folderId: existingTypebot.folderId,
ownerId: existingTypebot.ownerId, ownerId: existingTypebot.ownerId,
icon: existingTypebot.icon, icon: existingTypebot.icon,
workspaceId: existingTypebot.workspaceId,
}) })
export const createPublishedTypebot = async (typebot: PublicTypebot) => export const createPublishedTypebot = async (typebot: PublicTypebot) =>

View File

@ -2,14 +2,21 @@ import { User } from 'db'
import { loadStripe } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js'
import { sendRequest } from 'utils' 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) if (!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env') throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY) const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
const { data, error } = await sendRequest<{ sessionId: string }>({ const { data, error } = await sendRequest<{ sessionId: string }>({
method: 'POST', method: 'POST',
url: '/api/stripe/checkout', url: '/api/stripe/checkout',
body: { email: user.email, currency }, body: { email: user.email, currency, plan, workspaceId },
}) })
if (error || !data) return if (error || !data) return
return stripe?.redirectToCheckout({ return stripe?.redirectToCheckout({

View File

@ -36,7 +36,7 @@ export const updateCollaborator = (
collaborator: CollaboratorsOnTypebots collaborator: CollaboratorsOnTypebots
) => ) =>
sendRequest({ sendRequest({
method: 'PUT', method: 'PATCH',
url: `/api/typebots/${typebotId}/collaborators/${userId}`, url: `/api/typebots/${typebotId}/collaborators/${userId}`,
body: collaborator, body: collaborator,
}) })

View File

@ -36,10 +36,10 @@ export const sendInvitation = (
export const updateInvitation = ( export const updateInvitation = (
typebotId: string, typebotId: string,
email: string, email: string,
invitation: Omit<Invitation, 'createdAt'> invitation: Omit<Invitation, 'createdAt' | 'id'>
) => ) =>
sendRequest({ sendRequest({
method: 'PUT', method: 'PATCH',
url: `/api/typebots/${typebotId}/invitations/${email}`, url: `/api/typebots/${typebotId}/invitations/${email}`,
body: invitation, body: invitation,
}) })

View File

@ -64,18 +64,20 @@ export type TypebotInDashboard = Pick<
> >
export const useTypebots = ({ export const useTypebots = ({
folderId, folderId,
workspaceId,
allFolders, allFolders,
onError, onError,
}: { }: {
workspaceId?: string
folderId?: string folderId?: string
allFolders?: boolean allFolders?: boolean
onError: (error: Error) => void onError: (error: Error) => void
}) => { }) => {
const params = stringify({ folderId, allFolders }) const params = stringify({ folderId, allFolders, workspaceId })
const { data, error, mutate } = useSWR< const { data, error, mutate } = useSWR<
{ typebots: TypebotInDashboard[] }, { typebots: TypebotInDashboard[] },
Error Error
>(`/api/typebots?${params}`, fetcher, { >(workspaceId ? `/api/typebots?${params}` : null, fetcher, {
dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined, dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
}) })
if (error) onError(error) if (error) onError(error)
@ -88,10 +90,12 @@ export const useTypebots = ({
export const createTypebot = async ({ export const createTypebot = async ({
folderId, folderId,
}: Pick<Typebot, 'folderId'>) => { workspaceId,
}: Pick<Typebot, 'folderId' | 'workspaceId'>) => {
const typebot = { const typebot = {
folderId, folderId,
name: 'My typebot', name: 'My typebot',
workspaceId,
} }
return sendRequest<Typebot>({ return sendRequest<Typebot>({
url: `/api/typebots`, url: `/api/typebots`,
@ -379,13 +383,13 @@ export const parseDefaultPublicId = (name: string, id: string) =>
toKebabCase(name) + `-${id?.slice(-7)}` toKebabCase(name) + `-${id?.slice(-7)}`
export const parseNewTypebot = ({ export const parseNewTypebot = ({
ownerId,
folderId, folderId,
name, name,
ownerAvatarUrl, ownerAvatarUrl,
workspaceId,
}: { }: {
ownerId: string
folderId: string | null folderId: string | null
workspaceId: string
name: string name: string
ownerAvatarUrl?: string ownerAvatarUrl?: string
}): Omit< }): Omit<
@ -413,9 +417,10 @@ export const parseNewTypebot = ({
steps: [startStep], steps: [startStep],
} }
return { return {
ownerId: null,
folderId, folderId,
name, name,
ownerId, workspaceId,
blocks: [startBlock], blocks: [startBlock],
edges: [], edges: [],
variables: [], variables: [],

View File

@ -1,3 +1,3 @@
export * from './user' export * from './user'
export * from './customDomains' export * from '../customDomains'
export * from './credentials' export * from '../credentials'

Some files were not shown because too many files have changed in this diff Show More