feat(editor): ✨ Team workspaces
This commit is contained in:
@ -335,9 +335,8 @@ export const invitationToCollaborate = (
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
From now on you will see this
|
||||
typebot in your dashboard under
|
||||
the "Shared with me" button 👍
|
||||
From now on you will have access to this
|
||||
typebot in their workspace 👍
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -435,3 +435,19 @@ export const MouseIcon = (props: IconProps) => (
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -7,23 +7,40 @@ import {
|
||||
Text,
|
||||
HStack,
|
||||
Flex,
|
||||
Avatar,
|
||||
SkeletonCircle,
|
||||
Skeleton,
|
||||
Button,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { LogOutIcon, SettingsIcon } from 'assets/icons'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
HardDriveIcon,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from 'assets/icons'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
|
||||
|
||||
export const DashboardHeader = () => {
|
||||
const { user } = useUser()
|
||||
|
||||
const { workspace, workspaces, switchWorkspace, createWorkspace } =
|
||||
useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const handleLogOut = () => {
|
||||
localStorage.removeItem('workspaceId')
|
||||
signOut()
|
||||
}
|
||||
|
||||
const handleCreateNewWorkspace = () =>
|
||||
createWorkspace(user?.name ?? undefined)
|
||||
|
||||
return (
|
||||
<Flex w="full" borderBottomWidth="1px" justify="center">
|
||||
<Flex
|
||||
@ -40,34 +57,72 @@ export const DashboardHeader = () => {
|
||||
>
|
||||
<TypebotLogo w="30px" />
|
||||
</NextChakraLink>
|
||||
<Menu>
|
||||
<MenuButton>
|
||||
<HStack>
|
||||
<Skeleton isLoaded={user !== undefined}>
|
||||
<Text>{user?.name}</Text>
|
||||
</Skeleton>
|
||||
<SkeletonCircle isLoaded={user !== undefined}>
|
||||
<Avatar
|
||||
boxSize="35px"
|
||||
name={user?.name ?? undefined}
|
||||
src={user?.image ?? undefined}
|
||||
/>
|
||||
</SkeletonCircle>
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
as={NextChakraLink}
|
||||
href="/account"
|
||||
icon={<SettingsIcon />}
|
||||
>
|
||||
My account
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogOut} icon={<LogOutIcon />}>
|
||||
Log out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<HStack>
|
||||
{user && workspace && (
|
||||
<WorkspaceSettingsModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
user={user}
|
||||
workspace={workspace}
|
||||
/>
|
||||
)}
|
||||
<Button leftIcon={<SettingsIcon />} onClick={onOpen}>
|
||||
Settings & Members
|
||||
</Button>
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton as={Button} variant="outline" px="2">
|
||||
<HStack>
|
||||
<SkeletonCircle
|
||||
isLoaded={workspace !== undefined}
|
||||
alignItems="center"
|
||||
display="flex"
|
||||
boxSize="20px"
|
||||
>
|
||||
<EmojiOrImageIcon
|
||||
boxSize="20px"
|
||||
icon={workspace?.icon}
|
||||
defaultIcon={HardDriveIcon}
|
||||
/>
|
||||
</SkeletonCircle>
|
||||
{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>
|
||||
)
|
||||
|
@ -19,15 +19,14 @@ import {
|
||||
TypebotInDashboard,
|
||||
useTypebots,
|
||||
} from 'services/typebots'
|
||||
import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
|
||||
import { BackButton } from './FolderContent/BackButton'
|
||||
import { CreateBotButton } from './FolderContent/CreateBotButton'
|
||||
import { CreateFolderButton } from './FolderContent/CreateFolderButton'
|
||||
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
|
||||
import { SharedTypebotsButton } from './FolderContent/SharedTypebotsButton'
|
||||
import { TypebotButton } from './FolderContent/TypebotButton'
|
||||
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
type Props = { folder: DashboardFolder | null }
|
||||
|
||||
@ -35,6 +34,7 @@ const dragDistanceTolerance = 20
|
||||
|
||||
export const FolderContent = ({ folder }: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
const {
|
||||
setDraggedTypebot,
|
||||
@ -60,6 +60,7 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
isLoading: isFolderLoading,
|
||||
mutate: mutateFolders,
|
||||
} = useFolders({
|
||||
workspaceId: workspace?.id,
|
||||
parentId: folder?.id,
|
||||
onError: (error) => {
|
||||
toast({ title: "Couldn't fetch folders", description: error.message })
|
||||
@ -71,22 +72,13 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
isLoading: isTypebotLoading,
|
||||
mutate: mutateTypebots,
|
||||
} = useTypebots({
|
||||
workspaceId: workspace?.id,
|
||||
folderId: folder?.id,
|
||||
onError: (error) => {
|
||||
toast({ title: "Couldn't fetch typebots", description: error.message })
|
||||
},
|
||||
})
|
||||
|
||||
const { totalSharedTypebots } = useSharedTypebotsCount({
|
||||
userId: folder === null ? user?.id : undefined,
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Couldn't fetch shared typebots",
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
|
||||
if (!typebots) return
|
||||
const { error } = await patchTypebot(typebotId, {
|
||||
@ -97,9 +89,9 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
}
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!folders) return
|
||||
if (!folders || !workspace) return
|
||||
setIsCreatingFolder(true)
|
||||
const { error, data: newFolder } = await createFolder({
|
||||
const { error, data: newFolder } = await createFolder(workspace.id, {
|
||||
parentFolderId: folder?.id ?? null,
|
||||
})
|
||||
setIsCreatingFolder(false)
|
||||
@ -164,7 +156,7 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
|
||||
return (
|
||||
<Flex w="full" flex="1" justify="center">
|
||||
{typebots && user && folder === null && (
|
||||
{typebots && !isTypebotLoading && user && folder === null && (
|
||||
<OnboardingModal totalTypebots={typebots.length} />
|
||||
)}
|
||||
<Stack w="1000px" spacing={6}>
|
||||
@ -185,7 +177,6 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
isLoading={isTypebotLoading}
|
||||
isFirstBot={typebots?.length === 0 && folder === null}
|
||||
/>
|
||||
{totalSharedTypebots > 0 && <SharedTypebotsButton />}
|
||||
{isFolderLoading && <ButtonSkeleton />}
|
||||
{folders &&
|
||||
folders.map((folder) => (
|
||||
|
@ -2,18 +2,18 @@ import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
|
||||
import { FolderPlusIcon } from 'assets/icons'
|
||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
||||
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import React from 'react'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
|
||||
type Props = { isLoading: boolean; onClick: () => void }
|
||||
|
||||
export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const handleClick = () => {
|
||||
if (isFreePlan(user)) return onOpen()
|
||||
if (isFreePlan(workspace)) return onOpen()
|
||||
onClick()
|
||||
}
|
||||
return (
|
||||
@ -24,7 +24,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
>
|
||||
<HStack>
|
||||
<Text>Create a folder</Text>
|
||||
{isFreePlan(user) && <Tag colorScheme="orange">Pro</Tag>}
|
||||
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
|
||||
</HStack>
|
||||
<UpgradeModal
|
||||
isOpen={isOpen}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -20,7 +20,7 @@ import { deleteTypebot, importTypebot, getTypebot } from 'services/typebots'
|
||||
import { Typebot } from 'models'
|
||||
import { useTypebotDnd } from 'contexts/TypebotDndContext'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { TypebotIcon } from 'components/shared/TypebotHeader/TypebotIcon'
|
||||
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { Plan } from 'db'
|
||||
|
||||
@ -157,7 +157,7 @@ export const TypebotButton = ({
|
||||
alignItems="center"
|
||||
fontSize={'4xl'}
|
||||
>
|
||||
{<TypebotIcon icon={typebot.icon} boxSize={'35px'} />}
|
||||
{<EmojiOrImageIcon icon={typebot.icon} boxSize={'35px'} />}
|
||||
</Flex>
|
||||
<Text textAlign="center">{typebot.name}</Text>
|
||||
</VStack>
|
||||
|
@ -24,6 +24,7 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
|
||||
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
|
||||
const confettiCanon = useRef<confetti.CreateTypes>()
|
||||
const [chosenCategories, setChosenCategories] = useState<string[]>([])
|
||||
const [openedOnce, setOpenedOnce] = useState(false)
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -37,12 +38,16 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (openedOnce) return
|
||||
const isNewUser =
|
||||
user &&
|
||||
new Date(user?.createdAt as unknown as string).toDateString() ===
|
||||
new Date().toDateString() &&
|
||||
totalTypebots === 0
|
||||
if (isNewUser) onOpen()
|
||||
if (isNewUser) {
|
||||
onOpen()
|
||||
setOpenedOnce(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user])
|
||||
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -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'
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { MembersList } from './MembersList'
|
@ -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>
|
||||
)
|
||||
}
|
@ -12,8 +12,6 @@ import { useUser } from 'contexts/UserContext'
|
||||
import { GraphNavigation } from 'db'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export const EditorSection = () => <EditorSettings />
|
||||
|
||||
export const EditorSettings = () => {
|
||||
const { user, saveUser } = useUser()
|
||||
const [value, setValue] = useState<string>(
|
||||
@ -44,7 +42,7 @@ export const EditorSettings = () => {
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<Heading size="md">Navigation</Heading>
|
||||
<Heading size="md">Editor Navigation</Heading>
|
||||
<RadioGroup onChange={setValue} value={value}>
|
||||
<HStack spacing={4} w="full" align="stretch">
|
||||
{options.map((option) => (
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
|
@ -5,7 +5,7 @@ import {
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react'
|
||||
import { EditorSettings } from 'components/account/EditorSection'
|
||||
import { EditorSettings } from 'components/dashboard/WorkspaceSettingsModal/UserSettingsForm'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
|
@ -8,10 +8,10 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { GeneralSettings } from 'models'
|
||||
import React from 'react'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
|
||||
type Props = {
|
||||
generalSettings: GeneralSettings
|
||||
@ -23,8 +23,8 @@ export const GeneralSettingsForm = ({
|
||||
onGeneralSettingsChange,
|
||||
}: Props) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { user } = useUser()
|
||||
const isUserFreePlan = isFreePlan(user)
|
||||
const { workspace } = useWorkspace()
|
||||
const isUserFreePlan = isFreePlan(workspace)
|
||||
const handleSwitchChange = () => {
|
||||
if (generalSettings?.isBrandingEnabled && isUserFreePlan) return
|
||||
onGeneralSettingsChange({
|
||||
|
@ -13,16 +13,17 @@ import { TrashIcon } from 'assets/icons'
|
||||
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import React from 'react'
|
||||
import { parseDefaultPublicId } from 'services/typebots'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { isDefined, isNotDefined } from 'utils'
|
||||
import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown'
|
||||
import { EditableUrl } from './EditableUrl'
|
||||
import { integrationsList } from './integrations/EmbedButton'
|
||||
|
||||
export const ShareContent = () => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const { typebot, updateOnBothTypebots } = useTypebot()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -83,7 +84,7 @@ export const ShareContent = () => {
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
{isFreePlan(user) ? (
|
||||
{isFreePlan(workspace) ? (
|
||||
<UpgradeButton colorScheme="gray">
|
||||
<Text mr="2">Add my domain</Text>{' '}
|
||||
<Tag colorScheme="orange">Pro</Tag>
|
||||
|
@ -23,7 +23,7 @@ const hostnameRegex =
|
||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
|
||||
|
||||
type CustomDomainModalProps = {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
domain?: string
|
||||
@ -31,7 +31,7 @@ type CustomDomainModalProps = {
|
||||
}
|
||||
|
||||
export const CustomDomainModal = ({
|
||||
userId,
|
||||
workspaceId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNewDomain,
|
||||
@ -67,7 +67,7 @@ export const CustomDomainModal = ({
|
||||
const onAddDomainClick = async () => {
|
||||
if (!hostnameRegex.test(inputValue)) return
|
||||
setIsLoading(true)
|
||||
const { error } = await createCustomDomain(userId, {
|
||||
const { error } = await createCustomDomain(workspaceId, {
|
||||
name: inputValue,
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
@ -16,6 +16,7 @@ import React, { useState } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CustomDomainModal } from './CustomDomainModal'
|
||||
import { deleteCustomDomain, useCustomDomains } from 'services/user'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
currentCustomDomain?: string
|
||||
@ -29,26 +30,26 @@ export const CustomDomainsDropdown = ({
|
||||
}: Props) => {
|
||||
const [isDeleting, setIsDeleting] = useState('')
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { user } = useUser()
|
||||
const { customDomains, mutate } = useCustomDomains({
|
||||
userId: user?.id,
|
||||
onError: (error) =>
|
||||
toast({ title: error.name, description: error.message }),
|
||||
})
|
||||
const { workspace } = useWorkspace()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const { customDomains, mutate } = useCustomDomains({
|
||||
workspaceId: workspace?.id,
|
||||
onError: (error) =>
|
||||
toast({ title: error.name, description: error.message }),
|
||||
})
|
||||
|
||||
const handleMenuItemClick = (customDomain: string) => () =>
|
||||
onCustomDomainSelect(customDomain)
|
||||
|
||||
const handleDeleteDomainClick =
|
||||
(domainName: string) => async (e: React.MouseEvent) => {
|
||||
if (!user) return
|
||||
if (!workspace) return
|
||||
e.stopPropagation()
|
||||
setIsDeleting(domainName)
|
||||
const { error } = await deleteCustomDomain(user.id, domainName)
|
||||
const { error } = await deleteCustomDomain(workspace.id, domainName)
|
||||
setIsDeleting('')
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutate({
|
||||
@ -59,11 +60,11 @@ export const CustomDomainsDropdown = ({
|
||||
}
|
||||
|
||||
const handleNewDomain = (domain: string) => {
|
||||
if (!user) return
|
||||
if (!workspace) return
|
||||
mutate({
|
||||
customDomains: [
|
||||
...(customDomains ?? []),
|
||||
{ name: domain, ownerId: user?.id },
|
||||
{ name: domain, workspaceId: workspace?.id },
|
||||
],
|
||||
})
|
||||
handleMenuItemClick(domain)()
|
||||
@ -71,9 +72,9 @@ export const CustomDomainsDropdown = ({
|
||||
|
||||
return (
|
||||
<Menu isLazy placement="bottom-start" matchWidth>
|
||||
{user?.id && (
|
||||
{workspace?.id && (
|
||||
<CustomDomainModal
|
||||
userId={user.id}
|
||||
workspaceId={workspace.id}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onNewDomain={handleNewDomain}
|
||||
|
@ -12,10 +12,10 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import { CredentialsType } from 'models'
|
||||
import { deleteCredentials, useCredentials } from 'services/user'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
type: CredentialsType
|
||||
@ -36,13 +36,13 @@ export const CredentialsDropdown = ({
|
||||
...props
|
||||
}: Props) => {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const { credentials, mutate } = useCredentials({
|
||||
userId: user?.id,
|
||||
workspaceId: workspace?.id,
|
||||
})
|
||||
const [isDeleting, setIsDeleting] = useState<string>()
|
||||
|
||||
@ -84,9 +84,9 @@ export const CredentialsDropdown = ({
|
||||
const handleDeleteDomainClick =
|
||||
(credentialsId: string) => async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!user?.id) return
|
||||
if (!workspace?.id) return
|
||||
setIsDeleting(credentialsId)
|
||||
const { error } = await deleteCredentials(user?.id, credentialsId)
|
||||
const { error } = await deleteCredentials(workspace.id, credentialsId)
|
||||
setIsDeleting(undefined)
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
onCredentialsSelect(undefined)
|
||||
|
@ -4,22 +4,31 @@ import {
|
||||
chakra,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { ImageUploadContent } from '../ImageUploadContent'
|
||||
import { TypebotIcon } from './TypebotIcon'
|
||||
import { EmojiOrImageIcon } from './EmojiOrImageIcon'
|
||||
import { ImageUploadContent } from './ImageUploadContent'
|
||||
|
||||
type Props = { icon?: string | null; onChangeIcon: (icon: string) => void }
|
||||
type Props = {
|
||||
icon?: string | null
|
||||
onChangeIcon: (icon: string) => void
|
||||
boxSize?: string
|
||||
}
|
||||
|
||||
export const EditableTypebotIcon = ({ icon, onChangeIcon }: Props) => {
|
||||
export const EditableEmojiOrImageIcon = ({
|
||||
icon,
|
||||
onChangeIcon,
|
||||
boxSize,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Popover isLazy>
|
||||
{({ onClose }) => (
|
||||
<>
|
||||
<Tooltip label="Change icon">
|
||||
<chakra.span
|
||||
<Flex
|
||||
cursor="pointer"
|
||||
px="2"
|
||||
p="2"
|
||||
rounded="md"
|
||||
_hover={{ bgColor: 'gray.100' }}
|
||||
transition="background-color 0.2s"
|
||||
@ -27,10 +36,14 @@ export const EditableTypebotIcon = ({ icon, onChangeIcon }: Props) => {
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<chakra.span>
|
||||
<TypebotIcon icon={icon} emojiFontSize="2xl" />
|
||||
<EmojiOrImageIcon
|
||||
icon={icon}
|
||||
emojiFontSize="2xl"
|
||||
boxSize={boxSize}
|
||||
/>
|
||||
</chakra.span>
|
||||
</PopoverTrigger>
|
||||
</chakra.span>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<PopoverContent p="2">
|
||||
<ImageUploadContent
|
@ -1,17 +1,19 @@
|
||||
import { ToolIcon } from 'assets/icons'
|
||||
import React from 'react'
|
||||
import { chakra, Image } from '@chakra-ui/react'
|
||||
import { chakra, IconProps, Image } from '@chakra-ui/react'
|
||||
|
||||
type Props = {
|
||||
icon?: string | null
|
||||
emojiFontSize?: string
|
||||
boxSize?: string
|
||||
defaultIcon?: (props: IconProps) => JSX.Element
|
||||
}
|
||||
|
||||
export const TypebotIcon = ({
|
||||
export const EmojiOrImageIcon = ({
|
||||
icon,
|
||||
boxSize = '25px',
|
||||
emojiFontSize,
|
||||
defaultIcon = ToolIcon,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
@ -22,7 +24,7 @@ export const TypebotIcon = ({
|
||||
boxSize={boxSize}
|
||||
objectFit={icon.endsWith('.svg') ? undefined : 'cover'}
|
||||
alt="typebot icon"
|
||||
rounded="md"
|
||||
rounded="10%"
|
||||
/>
|
||||
) : (
|
||||
<chakra.span role="img" fontSize={emojiFontSize}>
|
||||
@ -30,7 +32,7 @@ export const TypebotIcon = ({
|
||||
</chakra.span>
|
||||
)
|
||||
) : (
|
||||
<ToolIcon boxSize={boxSize} />
|
||||
defaultIcon({ boxSize })
|
||||
)}
|
||||
</>
|
||||
)
|
@ -1,7 +1,7 @@
|
||||
import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react'
|
||||
import { useGraph } from 'contexts/GraphContext'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import React, { useMemo } from 'react'
|
||||
import { AnswersCount } from 'services/analytics'
|
||||
import {
|
||||
@ -9,7 +9,7 @@ import {
|
||||
computeSourceCoordinates,
|
||||
computeDropOffPath,
|
||||
} from 'services/graph'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { byId, isDefined } from 'utils'
|
||||
|
||||
type Props = {
|
||||
@ -23,11 +23,11 @@ export const DropOffEdge = ({
|
||||
blockId,
|
||||
onUnlockProPlanClick,
|
||||
}: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const { sourceEndpoints, blocksCoordinates, graphPosition } = useGraph()
|
||||
const { publishedTypebot } = useTypebot()
|
||||
|
||||
const isUserOnFreePlan = isFreePlan(user)
|
||||
const isUserOnFreePlan = isFreePlan(workspace)
|
||||
|
||||
const totalAnswers = useMemo(
|
||||
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
import { GoogleLogo } from 'assets/logos'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { Info } from 'components/shared/Info'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import React from 'react'
|
||||
import { getGoogleSheetsConsentScreenUrl } from 'services/integrations'
|
||||
|
||||
@ -25,6 +26,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
|
||||
const { workspace } = useWorkspace()
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
@ -54,7 +56,8 @@ export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
|
||||
variant="outline"
|
||||
href={getGoogleSheetsConsentScreenUrl(
|
||||
window.location.href,
|
||||
stepId
|
||||
stepId,
|
||||
workspace?.id
|
||||
)}
|
||||
mx="auto"
|
||||
>
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { Stack, useDisclosure, Text } from '@chakra-ui/react'
|
||||
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
||||
import { Input, Textarea } from 'components/shared/Textbox'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { CredentialsType, SendEmailOptions } from 'models'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
import React, { useState } from 'react'
|
||||
import { SmtpConfigModal } from './SmtpConfigModal'
|
||||
|
||||
type Props = {
|
||||
@ -13,16 +11,9 @@ type Props = {
|
||||
}
|
||||
|
||||
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
const { owner } = useTypebot()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(options.replyTo) || !owner?.email) return
|
||||
handleReplyToChange(owner.email)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCredentialsSelect = (credentialsId?: string) => {
|
||||
setRefreshCredentialsKey(refreshCredentialsKey + 1)
|
||||
onOptionsChange({
|
||||
@ -95,7 +86,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
<Input
|
||||
onChange={handleReplyToChange}
|
||||
defaultValue={options.replyTo}
|
||||
placeholder={owner?.email ?? 'email@gmail.com'}
|
||||
placeholder={'email@gmail.com'}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
|
@ -16,6 +16,7 @@ import { createCredentials } from 'services/user'
|
||||
import { testSmtpConfig } from 'services/integrations'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { SmtpConfigForm } from './SmtpConfigForm'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
@ -29,6 +30,7 @@ export const SmtpConfigModal = ({
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -40,7 +42,7 @@ export const SmtpConfigModal = ({
|
||||
})
|
||||
|
||||
const handleCreateClick = async () => {
|
||||
if (!user?.email) return
|
||||
if (!user?.email || !workspace?.id) return
|
||||
setIsCreating(true)
|
||||
const { error: testSmtpError } = await testSmtpConfig(
|
||||
smtpConfig,
|
||||
@ -53,10 +55,11 @@ export const SmtpConfigModal = ({
|
||||
description: "We couldn't send the test email with your configuration",
|
||||
})
|
||||
}
|
||||
const { data, error } = await createCredentials(user.id, {
|
||||
const { data, error } = await createCredentials({
|
||||
data: smtpConfig,
|
||||
name: smtpConfig.from.email as string,
|
||||
type: CredentialsType.SMTP,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
setIsCreating(false)
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
|
@ -23,10 +23,13 @@ export const TypebotLinkSettingsForm = ({
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TypebotsDropdown
|
||||
typebotId={options.typebotId}
|
||||
onSelectTypebotId={handleTypebotIdChange}
|
||||
/>
|
||||
{typebot && (
|
||||
<TypebotsDropdown
|
||||
typebotId={options.typebotId}
|
||||
onSelectTypebotId={handleTypebotIdChange}
|
||||
currentWorkspaceId={typebot.workspaceId as string}
|
||||
/>
|
||||
)}
|
||||
<BlocksDropdown
|
||||
blocks={
|
||||
typebot &&
|
||||
|
@ -9,16 +9,22 @@ import { byId } from 'utils'
|
||||
|
||||
type Props = {
|
||||
typebotId?: string
|
||||
currentWorkspaceId: string
|
||||
onSelectTypebotId: (typebotId: string | 'current') => void
|
||||
}
|
||||
|
||||
export const TypebotsDropdown = ({ typebotId, onSelectTypebotId }: Props) => {
|
||||
export const TypebotsDropdown = ({
|
||||
typebotId,
|
||||
onSelectTypebotId,
|
||||
currentWorkspaceId,
|
||||
}: Props) => {
|
||||
const { query } = useRouter()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const { typebots, isLoading } = useTypebots({
|
||||
workspaceId: currentWorkspaceId,
|
||||
allFolders: true,
|
||||
onError: (e) => toast({ title: e.name, description: e.message }),
|
||||
})
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
import { UpgradeModal } from './modals/UpgradeModal'
|
||||
import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
|
||||
@ -22,14 +23,16 @@ export const PublishFirstInfo = (props: AlertProps) => (
|
||||
<Info {...props}>You need to publish your typebot first</Info>
|
||||
)
|
||||
|
||||
export const UnlockProPlanInfo = ({
|
||||
export const UnlockPlanInfo = ({
|
||||
contentLabel,
|
||||
buttonLabel,
|
||||
buttonLabel = 'More info',
|
||||
type,
|
||||
plan = Plan.PRO,
|
||||
}: {
|
||||
contentLabel: string
|
||||
buttonLabel: string
|
||||
buttonLabel?: string
|
||||
type?: LimitReached
|
||||
plan: Plan
|
||||
}) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
return (
|
||||
@ -44,10 +47,10 @@ export const UnlockProPlanInfo = ({
|
||||
<AlertIcon />
|
||||
<Text>{contentLabel}</Text>
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={onOpen}>
|
||||
<Button colorScheme="blue" onClick={onOpen} flexShrink={0} ml="2">
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} />
|
||||
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
13
apps/builder/components/shared/MaintenancePage.tsx
Normal file
13
apps/builder/components/shared/MaintenancePage.tsx
Normal 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>
|
||||
)
|
@ -1,13 +1,16 @@
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan } from 'db'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { isCloudProdInstance } from 'services/utils'
|
||||
import { planToReadable } from 'services/workspace'
|
||||
import { initBubble } from 'typebot-js'
|
||||
|
||||
export const SupportBubble = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const [localTypebotId, setLocalTypebotId] = useState(typebot?.id)
|
||||
const [localUserId, setLocalUserId] = useState(user?.id)
|
||||
|
||||
@ -33,7 +36,7 @@ export const SupportBubble = () => {
|
||||
Email: user?.email ?? undefined,
|
||||
'Typebot ID': typebot?.id,
|
||||
'Avatar URL': user?.image ?? undefined,
|
||||
Plan: planToReadable(user?.plan),
|
||||
Plan: planToReadable(workspace?.plan),
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -42,17 +45,3 @@ export const SupportBubble = () => {
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
const planToReadable = (plan?: Plan) => {
|
||||
if (!plan) return
|
||||
switch (plan) {
|
||||
case 'FREE':
|
||||
return 'Free'
|
||||
case 'LIFETIME':
|
||||
return 'Lifetime'
|
||||
case 'OFFERED':
|
||||
return 'Offered'
|
||||
case 'PRO':
|
||||
return 'Pro'
|
||||
}
|
||||
}
|
||||
|
@ -10,11 +10,15 @@ import {
|
||||
MenuList,
|
||||
SkeletonCircle,
|
||||
SkeletonText,
|
||||
Text,
|
||||
Tag,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CollaborationType } from 'db'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { CollaborationType, WorkspaceRole } from 'db'
|
||||
import React, { FormEvent, useState } from 'react'
|
||||
import {
|
||||
deleteCollaborator,
|
||||
@ -27,21 +31,19 @@ import {
|
||||
deleteInvitation,
|
||||
sendInvitation,
|
||||
} from 'services/typebots/invitations'
|
||||
import {
|
||||
CollaboratorIdentityContent,
|
||||
CollaboratorItem,
|
||||
} from './CollaboratorButton'
|
||||
import { CollaboratorItem } from './CollaboratorButton'
|
||||
|
||||
export const CollaborationList = () => {
|
||||
const { user } = useUser()
|
||||
const { typebot, owner } = useTypebot()
|
||||
const { currentRole, workspace } = useWorkspace()
|
||||
const { typebot } = useTypebot()
|
||||
const [invitationType, setInvitationType] = useState<CollaborationType>(
|
||||
CollaborationType.READ
|
||||
)
|
||||
const [invitationEmail, setInvitationEmail] = useState('')
|
||||
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
|
||||
|
||||
const isOwner = user?.email === owner?.email
|
||||
const hasFullAccess =
|
||||
(currentRole && currentRole !== WorkspaceRole.GUEST) || false
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -66,12 +68,12 @@ export const CollaborationList = () => {
|
||||
} = useInvitations({
|
||||
typebotId: typebot?.id,
|
||||
onError: (e) =>
|
||||
toast({ title: "Couldn't fetch collaborators", description: e.message }),
|
||||
toast({ title: "Couldn't fetch invitations", description: e.message }),
|
||||
})
|
||||
|
||||
const handleChangeInvitationCollabType =
|
||||
(email: string) => async (type: CollaborationType) => {
|
||||
if (!typebot || !isOwner) return
|
||||
if (!typebot || !hasFullAccess) return
|
||||
const { error } = await updateInvitation(typebot?.id, email, {
|
||||
email,
|
||||
typebotId: typebot.id,
|
||||
@ -85,7 +87,7 @@ export const CollaborationList = () => {
|
||||
})
|
||||
}
|
||||
const handleDeleteInvitation = (email: string) => async () => {
|
||||
if (!typebot || !isOwner) return
|
||||
if (!typebot || !hasFullAccess) return
|
||||
const { error } = await deleteInvitation(typebot?.id, email)
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutateInvitations({
|
||||
@ -95,7 +97,7 @@ export const CollaborationList = () => {
|
||||
|
||||
const handleChangeCollaborationType =
|
||||
(userId: string) => async (type: CollaborationType) => {
|
||||
if (!typebot || !isOwner) return
|
||||
if (!typebot || !hasFullAccess) return
|
||||
const { error } = await updateCollaborator(typebot?.id, userId, {
|
||||
userId,
|
||||
type,
|
||||
@ -109,7 +111,7 @@ export const CollaborationList = () => {
|
||||
})
|
||||
}
|
||||
const handleDeleteCollaboration = (userId: string) => async () => {
|
||||
if (!typebot || !isOwner) return
|
||||
if (!typebot || !hasFullAccess) return
|
||||
const { error } = await deleteCollaborator(typebot?.id, userId)
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutateCollaborators({
|
||||
@ -119,7 +121,7 @@ export const CollaborationList = () => {
|
||||
|
||||
const handleInvitationSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!typebot || !isOwner) return
|
||||
if (!typebot || !hasFullAccess) return
|
||||
setIsSendingInvitation(true)
|
||||
const { error } = await sendInvitation(typebot.id, {
|
||||
email: invitationEmail,
|
||||
@ -133,60 +135,57 @@ export const CollaborationList = () => {
|
||||
setInvitationEmail('')
|
||||
}
|
||||
|
||||
const hasNobody =
|
||||
(collaborators ?? []).length > 0 ||
|
||||
((invitations ?? []).length > 0 &&
|
||||
!isInvitationsLoading &&
|
||||
!isCollaboratorsLoading)
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{isOwner && (
|
||||
<HStack
|
||||
as="form"
|
||||
onSubmit={handleInvitationSubmit}
|
||||
pt="4"
|
||||
px="4"
|
||||
pb={hasNobody ? '0' : '4'}
|
||||
>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="colleague@company.com"
|
||||
name="inviteEmail"
|
||||
value={invitationEmail}
|
||||
onChange={(e) => setInvitationEmail(e.target.value)}
|
||||
rounded="md"
|
||||
/>
|
||||
<Stack spacing={4} py="4">
|
||||
<HStack as="form" onSubmit={handleInvitationSubmit} px="4">
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="colleague@company.com"
|
||||
name="inviteEmail"
|
||||
value={invitationEmail}
|
||||
onChange={(e) => setInvitationEmail(e.target.value)}
|
||||
rounded="md"
|
||||
isDisabled={!hasFullAccess}
|
||||
/>
|
||||
|
||||
{hasFullAccess && (
|
||||
<CollaborationTypeMenuButton
|
||||
type={invitationType}
|
||||
onChange={setInvitationType}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
isLoading={isSendingInvitation}
|
||||
flexShrink={0}
|
||||
type="submit"
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
{owner && (collaborators ?? []).length > 0 && (
|
||||
<CollaboratorIdentityContent
|
||||
email={owner.email ?? ''}
|
||||
name={owner.name ?? undefined}
|
||||
image={owner.image ?? undefined}
|
||||
tag="Owner"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
isLoading={isSendingInvitation}
|
||||
flexShrink={0}
|
||||
type="submit"
|
||||
isDisabled={!hasFullAccess}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</HStack>
|
||||
{workspace && (
|
||||
<Flex py="2" px="4" justifyContent="space-between">
|
||||
<HStack minW={0}>
|
||||
<EmojiOrImageIcon icon={workspace.icon} />
|
||||
<Text fontSize="15px" noOfLines={0}>
|
||||
Everyone at {workspace.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Tag>
|
||||
{convertCollaborationTypeEnumToReadable(
|
||||
CollaborationType.FULL_ACCESS
|
||||
)}
|
||||
</Tag>
|
||||
</Flex>
|
||||
)}
|
||||
{invitations?.map(({ email, type }) => (
|
||||
<CollaboratorItem
|
||||
key={email}
|
||||
email={email}
|
||||
type={type}
|
||||
isOwner={isOwner}
|
||||
isOwner={hasFullAccess}
|
||||
onDeleteClick={handleDeleteInvitation(email)}
|
||||
onChangeCollaborationType={handleChangeInvitationCollabType(email)}
|
||||
isGuest
|
||||
@ -199,7 +198,7 @@ export const CollaborationList = () => {
|
||||
image={user.image ?? undefined}
|
||||
name={user.name ?? undefined}
|
||||
type={type}
|
||||
isOwner={isOwner}
|
||||
isOwner={hasFullAccess}
|
||||
onDeleteClick={handleDeleteCollaboration(userId ?? '')}
|
||||
onChangeCollaborationType={handleChangeCollaborationType(userId)}
|
||||
/>
|
||||
@ -253,5 +252,7 @@ export const convertCollaborationTypeEnumToReadable = (
|
||||
return 'Can view'
|
||||
case CollaborationType.WRITE:
|
||||
return 'Can edit'
|
||||
case CollaborationType.FULL_ACCESS:
|
||||
return 'Full access'
|
||||
}
|
||||
}
|
||||
|
@ -15,8 +15,8 @@ import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { PublishButton } from '../buttons/PublishButton'
|
||||
import { EditableEmojiOrImageIcon } from '../EditableEmojiOrImageIcon'
|
||||
import { CollaborationMenuButton } from './CollaborationMenuButton'
|
||||
import { EditableTypebotIcon } from './EditableTypebotIcons'
|
||||
import { EditableTypebotName } from './EditableTypebotName'
|
||||
|
||||
export const headerHeight = 56
|
||||
@ -123,7 +123,7 @@ export const TypebotHeader = () => {
|
||||
}
|
||||
/>
|
||||
<HStack spacing={1}>
|
||||
<EditableTypebotIcon
|
||||
<EditableEmojiOrImageIcon
|
||||
icon={typebot?.icon}
|
||||
onChangeIcon={handleChangeIcon}
|
||||
/>
|
||||
|
@ -1,20 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Text,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Stack,
|
||||
ListItem,
|
||||
UnorderedList,
|
||||
ListIcon,
|
||||
chakra,
|
||||
Tooltip,
|
||||
ListProps,
|
||||
Button,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { PricingCard } from './PricingCard'
|
||||
import { ActionButton } from './ActionButton'
|
||||
import { pay } from 'services/stripe'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { Plan } from 'db'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { CheckIcon } from 'assets/icons'
|
||||
|
||||
export enum LimitReached {
|
||||
BRAND = 'Remove branding',
|
||||
@ -26,10 +34,16 @@ type UpgradeModalProps = {
|
||||
type?: LimitReached
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
plan?: Plan
|
||||
}
|
||||
|
||||
export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => {
|
||||
export const UpgradeModal = ({
|
||||
onClose,
|
||||
isOpen,
|
||||
plan = Plan.PRO,
|
||||
}: UpgradeModalProps) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const [payLoading, setPayLoading] = useState(false)
|
||||
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd')
|
||||
|
||||
@ -39,64 +53,133 @@ export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => {
|
||||
)
|
||||
}, [])
|
||||
|
||||
let limitLabel
|
||||
switch (type) {
|
||||
case LimitReached.BRAND: {
|
||||
limitLabel = "You can't hide Typebot brand on the Basic plan"
|
||||
break
|
||||
}
|
||||
case LimitReached.CUSTOM_DOMAIN: {
|
||||
limitLabel = "You can't add your domain with the Basic plan"
|
||||
break
|
||||
}
|
||||
case LimitReached.FOLDER: {
|
||||
limitLabel = "You can't create folders with the basic plan"
|
||||
}
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
if (!user) return
|
||||
if (!user || !workspace) return
|
||||
setPayLoading(true)
|
||||
await pay(user, currency)
|
||||
await pay({
|
||||
user,
|
||||
currency,
|
||||
plan: plan === Plan.TEAM ? 'team' : 'pro',
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Upgrade to Pro plan</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Stack} spacing={6} alignItems="center">
|
||||
{limitLabel && (
|
||||
<Alert status="warning" rounded="md">
|
||||
<AlertIcon />
|
||||
{limitLabel}
|
||||
</Alert>
|
||||
<ModalBody as={Stack} pt="10">
|
||||
{plan === Plan.PRO ? (
|
||||
<PersonalProPlanContent currency={currency} />
|
||||
) : (
|
||||
<TeamPlanContent currency={currency} />
|
||||
)}
|
||||
<PricingCard
|
||||
data={{
|
||||
price: currency === 'eur' ? '25€' : '$30',
|
||||
name: 'Pro plan',
|
||||
features: [
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<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',
|
||||
'View incomplete submissions',
|
||||
'In-depth drop off analytics',
|
||||
'Custom domains',
|
||||
'Organize typebots in folders',
|
||||
'Unlimited uploads',
|
||||
'Custom Google Analytics events',
|
||||
],
|
||||
}}
|
||||
button={
|
||||
<ActionButton onClick={handlePayClick} isLoading={payLoading}>
|
||||
Upgrade now
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
]}
|
||||
spacing="0"
|
||||
/>
|
||||
}
|
||||
hasArrow
|
||||
placement="top"
|
||||
>
|
||||
<chakra.span textDecoration="underline" cursor="pointer">
|
||||
Everything in Pro
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
, plus:
|
||||
</Text>
|
||||
<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>
|
||||
)
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { ToolIcon, TemplateIcon, DownloadIcon } from 'assets/icons'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Typebot } from 'models'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useState } from 'react'
|
||||
@ -16,6 +17,7 @@ import { ImportTypebotFromFileButton } from './ImportTypebotFromFileButton'
|
||||
import { TemplatesModal } from './TemplatesModal'
|
||||
|
||||
export const CreateNewTypebotButtons = () => {
|
||||
const { workspace } = useWorkspace()
|
||||
const { user } = useUser()
|
||||
const router = useRouter()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
@ -29,15 +31,16 @@ export const CreateNewTypebotButtons = () => {
|
||||
})
|
||||
|
||||
const handleCreateSubmit = async (typebot?: Typebot) => {
|
||||
if (!user) return
|
||||
if (!user || !workspace) return
|
||||
setIsLoading(true)
|
||||
const folderId = router.query.folderId?.toString() ?? null
|
||||
const { error, data } = typebot
|
||||
? await importTypebot(
|
||||
{
|
||||
...typebot,
|
||||
ownerId: user.id,
|
||||
folderId,
|
||||
workspaceId: workspace.id,
|
||||
ownerId: user.id,
|
||||
theme: {
|
||||
...typebot.theme,
|
||||
chat: {
|
||||
@ -46,10 +49,11 @@ export const CreateNewTypebotButtons = () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
user.plan
|
||||
workspace.plan
|
||||
)
|
||||
: await createTypebot({
|
||||
folderId,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
if (error) toast({ description: error.message })
|
||||
if (data)
|
||||
|
@ -40,7 +40,6 @@ import useUndo from 'services/utils/useUndo'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { itemsAction, ItemsActions } from './actions/items'
|
||||
import { dequal } from 'dequal'
|
||||
import { User } from 'db'
|
||||
import { saveWebhook } from 'services/webhook'
|
||||
import { stringify } from 'qs'
|
||||
import cuid from 'cuid'
|
||||
@ -63,7 +62,6 @@ const typebotContext = createContext<
|
||||
typebot?: Typebot
|
||||
publishedTypebot?: PublicTypebot
|
||||
linkedTypebots?: Typebot[]
|
||||
owner?: User
|
||||
webhooks: Webhook[]
|
||||
isReadOnly?: boolean
|
||||
isPublished: boolean
|
||||
@ -108,22 +106,15 @@ export const TypebotContext = ({
|
||||
status: 'error',
|
||||
})
|
||||
|
||||
const {
|
||||
typebot,
|
||||
publishedTypebot,
|
||||
owner,
|
||||
webhooks,
|
||||
isReadOnly,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useFetchedTypebot({
|
||||
typebotId,
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: 'Error while fetching typebot',
|
||||
description: error.message,
|
||||
}),
|
||||
})
|
||||
const { typebot, publishedTypebot, webhooks, isReadOnly, isLoading, mutate } =
|
||||
useFetchedTypebot({
|
||||
typebotId,
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: 'Error while fetching typebot',
|
||||
description: error.message,
|
||||
}),
|
||||
})
|
||||
|
||||
const [
|
||||
{ present: localTypebot },
|
||||
@ -150,6 +141,7 @@ export const TypebotContext = ({
|
||||
)
|
||||
|
||||
const { typebots: linkedTypebots } = useLinkedTypebots({
|
||||
workspaceId: localTypebot?.workspaceId ?? undefined,
|
||||
typebotId,
|
||||
typebotIds: linkedTypebotIds,
|
||||
onError: (error) =>
|
||||
@ -373,7 +365,6 @@ export const TypebotContext = ({
|
||||
typebot: localTypebot,
|
||||
publishedTypebot,
|
||||
linkedTypebots,
|
||||
owner,
|
||||
webhooks: webhooks ?? [],
|
||||
isReadOnly,
|
||||
isSavingLoading,
|
||||
@ -415,17 +406,17 @@ export const useFetchedTypebot = ({
|
||||
typebot: Typebot
|
||||
webhooks: Webhook[]
|
||||
publishedTypebot?: PublicTypebot
|
||||
owner?: User
|
||||
isReadOnly?: boolean
|
||||
},
|
||||
Error
|
||||
>(`/api/typebots/${typebotId}`, fetcher, { dedupingInterval: 0 })
|
||||
>(`/api/typebots/${typebotId}`, fetcher, {
|
||||
dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
|
||||
})
|
||||
if (error) onError(error)
|
||||
return {
|
||||
typebot: data?.typebot,
|
||||
webhooks: data?.webhooks,
|
||||
publishedTypebot: data?.publishedTypebot,
|
||||
owner: data?.owner,
|
||||
isReadOnly: data?.isReadOnly,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
@ -433,15 +424,17 @@ export const useFetchedTypebot = ({
|
||||
}
|
||||
|
||||
const useLinkedTypebots = ({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
typebotIds,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId?: string
|
||||
typebotId?: string
|
||||
typebotIds?: string[]
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const params = stringify({ typebotIds }, { indices: false })
|
||||
const params = stringify({ typebotIds, workspaceId }, { indices: false })
|
||||
const { data, error, mutate } = useSWR<
|
||||
{
|
||||
typebots: Typebot[]
|
||||
|
@ -21,6 +21,7 @@ const userContext = createContext<{
|
||||
isSaving: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
isOAuthProvider: boolean
|
||||
currentWorkspaceId?: string
|
||||
updateUser: (newUser: Partial<User>) => void
|
||||
saveUser: (newUser?: Partial<User>) => Promise<void>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@ -35,6 +36,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>()
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const isOAuthProvider = useMemo(
|
||||
@ -49,6 +51,9 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(user) || isNotDefined(session)) return
|
||||
setCurrentWorkspaceId(
|
||||
localStorage.getItem('currentWorkspaceId') ?? undefined
|
||||
)
|
||||
const parsedUser = session.user as User
|
||||
setUser(parsedUser)
|
||||
if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
|
||||
@ -96,6 +101,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
isLoading: status === 'loading',
|
||||
hasUnsavedChanges,
|
||||
isOAuthProvider,
|
||||
currentWorkspaceId,
|
||||
updateUser,
|
||||
saveUser,
|
||||
}}
|
||||
|
144
apps/builder/contexts/WorkspaceContext.tsx
Normal file
144
apps/builder/contexts/WorkspaceContext.tsx
Normal 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)
|
@ -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>
|
||||
)
|
||||
}
|
@ -1,17 +1,17 @@
|
||||
import { Button, Flex, HStack, Tag, useToast, Text } from '@chakra-ui/react'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useStats } from 'services/analytics'
|
||||
import { isFreePlan } from 'services/user/user'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { AnalyticsContent } from './AnalyticsContent'
|
||||
import { SubmissionsContent } from './SubmissionContent'
|
||||
|
||||
export const ResultsContent = () => {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const isAnalytics = useMemo(
|
||||
() => router.pathname.endsWith('analytics'),
|
||||
@ -81,7 +81,7 @@ export const ResultsContent = () => {
|
||||
onDeleteResults={handleDeletedResults}
|
||||
totalResults={stats?.totalStarts ?? 0}
|
||||
totalHiddenResults={
|
||||
isFreePlan(user)
|
||||
isFreePlan(workspace)
|
||||
? (stats?.totalStarts ?? 0) - (stats?.totalCompleted ?? 0)
|
||||
: undefined
|
||||
}
|
||||
|
@ -10,10 +10,11 @@ import {
|
||||
useResults,
|
||||
} from 'services/typebots'
|
||||
import { unparse } from 'papaparse'
|
||||
import { UnlockProPlanInfo } from 'components/shared/Info'
|
||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||
import { LogsModal } from './LogsModal'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { isDefined, parseResultHeader } from 'utils'
|
||||
import { Plan } from 'db'
|
||||
|
||||
type Props = {
|
||||
typebotId: string
|
||||
@ -147,9 +148,10 @@ export const SubmissionsContent = ({
|
||||
return (
|
||||
<Stack maxW="1200px" w="full" pb="28" px={['4', '0']} spacing="4">
|
||||
{totalHiddenResults && (
|
||||
<UnlockProPlanInfo
|
||||
<UnlockPlanInfo
|
||||
buttonLabel={`Unlock ${totalHiddenResults} results`}
|
||||
contentLabel="You are seeing complete submissions only."
|
||||
plan={Plan.PRO}
|
||||
/>
|
||||
)}
|
||||
{publishedTypebot && (
|
||||
|
@ -17,9 +17,9 @@ export const getAuthenticatedGoogleClient = async (
|
||||
{ client: OAuth2Client; credentials: CredentialsFromDb } | undefined
|
||||
> => {
|
||||
const credentials = (await prisma.credentials.findFirst({
|
||||
where: { id: credentialsId, ownerId: userId },
|
||||
where: { id: credentialsId, workspace: { members: { some: { userId } } } },
|
||||
})) as CredentialsFromDb | undefined
|
||||
if (!credentials || credentials.ownerId !== userId) return
|
||||
if (!credentials) return
|
||||
const data = decrypt(
|
||||
credentials.data,
|
||||
credentials.iv
|
||||
|
@ -17,6 +17,8 @@ import { KBarProvider } from 'kbar'
|
||||
import { actions } from 'libs/kbar'
|
||||
import { enableMocks } from 'mocks'
|
||||
import { SupportBubble } from 'components/shared/SupportBubble'
|
||||
import { WorkspaceContext } from 'contexts/WorkspaceContext'
|
||||
import { MaintenancePage } from 'components/shared/MaintenancePage'
|
||||
|
||||
if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks()
|
||||
|
||||
@ -31,27 +33,30 @@ const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => {
|
||||
}, [pathname])
|
||||
|
||||
const typebotId = query.typebotId?.toString()
|
||||
return (
|
||||
<ChakraProvider theme={customTheme}>
|
||||
<KBarProvider actions={actions}>
|
||||
<SessionProvider session={session}>
|
||||
<UserContext>
|
||||
{typebotId ? (
|
||||
<TypebotContext typebotId={typebotId}>
|
||||
<Component />
|
||||
<SupportBubble />
|
||||
</TypebotContext>
|
||||
) : (
|
||||
<>
|
||||
<Component {...pageProps} />
|
||||
<SupportBubble />
|
||||
</>
|
||||
)}
|
||||
</UserContext>
|
||||
</SessionProvider>
|
||||
</KBarProvider>
|
||||
</ChakraProvider>
|
||||
)
|
||||
return <MaintenancePage />
|
||||
// return (
|
||||
// <ChakraProvider theme={customTheme}>
|
||||
// <KBarProvider actions={actions}>
|
||||
// <SessionProvider session={session}>
|
||||
// <UserContext>
|
||||
// {typebotId ? (
|
||||
// <TypebotContext typebotId={typebotId}>
|
||||
// <WorkspaceContext>
|
||||
// <Component />
|
||||
// <SupportBubble />
|
||||
// </WorkspaceContext>
|
||||
// </TypebotContext>
|
||||
// ) : (
|
||||
// <WorkspaceContext>
|
||||
// <Component {...pageProps} />
|
||||
// <SupportBubble />
|
||||
// </WorkspaceContext>
|
||||
// )}
|
||||
// </UserContext>
|
||||
// </SessionProvider>
|
||||
// </KBarProvider>
|
||||
// </ChakraProvider>
|
||||
// )
|
||||
}
|
||||
|
||||
export default App
|
||||
|
@ -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
|
@ -1,16 +1,33 @@
|
||||
// Forked from https://github.com/nextauthjs/adapters/blob/main/packages/prisma/src/index.ts
|
||||
import { PrismaClient, Prisma, Invitation, Plan } from 'db'
|
||||
import {
|
||||
PrismaClient,
|
||||
Prisma,
|
||||
Invitation,
|
||||
Plan,
|
||||
WorkspaceRole,
|
||||
WorkspaceInvitation,
|
||||
} from 'db'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Adapter, AdapterUser } from 'next-auth/adapters'
|
||||
import cuid from 'cuid'
|
||||
import { got } from 'got'
|
||||
|
||||
type InvitationWithWorkspaceId = Invitation & {
|
||||
typebot: {
|
||||
workspaceId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomAdapter(p: PrismaClient): Adapter {
|
||||
return {
|
||||
createUser: async (data: Omit<AdapterUser, 'id'>) => {
|
||||
const user = { id: cuid(), email: data.email as string }
|
||||
const invitations = await p.invitation.findMany({
|
||||
where: { email: user.email },
|
||||
include: { typebot: { select: { workspaceId: true } } },
|
||||
})
|
||||
const workspaceInvitations = await p.workspaceInvitation.findMany({
|
||||
where: { email: user.email },
|
||||
})
|
||||
const createdUser = await p.user.create({
|
||||
data: {
|
||||
@ -18,6 +35,25 @@ export function CustomAdapter(p: PrismaClient): Adapter {
|
||||
id: user.id,
|
||||
apiToken: randomUUID(),
|
||||
plan: process.env.ADMIN_EMAIL === data.email ? Plan.PRO : Plan.FREE,
|
||||
workspaces:
|
||||
workspaceInvitations.length > 0
|
||||
? undefined
|
||||
: {
|
||||
create: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
workspace: {
|
||||
create: {
|
||||
name: data.name
|
||||
? `${data.name}'s workspace`
|
||||
: `My workspace`,
|
||||
plan:
|
||||
process.env.ADMIN_EMAIL === data.email
|
||||
? Plan.TEAM
|
||||
: Plan.FREE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (process.env.USER_CREATED_WEBHOOK_URL)
|
||||
@ -29,6 +65,8 @@ export function CustomAdapter(p: PrismaClient): Adapter {
|
||||
})
|
||||
if (invitations.length > 0)
|
||||
await convertInvitationsToCollaborations(p, user, invitations)
|
||||
if (workspaceInvitations.length > 0)
|
||||
await joinWorkspaces(p, user, workspaceInvitations)
|
||||
return createdUser
|
||||
},
|
||||
getUser: (id) => p.user.findUnique({ where: { id } }),
|
||||
@ -59,7 +97,7 @@ export function CustomAdapter(p: PrismaClient): Adapter {
|
||||
oauth_token_secret: data.oauth_token_secret as string,
|
||||
oauth_token: data.oauth_token as string,
|
||||
refresh_token_expires_in: data.refresh_token_expires_in as number,
|
||||
}
|
||||
},
|
||||
}) as any
|
||||
},
|
||||
unlinkAccount: (provider_providerAccountId) =>
|
||||
@ -94,7 +132,7 @@ export function CustomAdapter(p: PrismaClient): Adapter {
|
||||
const convertInvitationsToCollaborations = async (
|
||||
p: PrismaClient,
|
||||
{ id, email }: { id: string; email: string },
|
||||
invitations: Invitation[]
|
||||
invitations: InvitationWithWorkspaceId[]
|
||||
) => {
|
||||
await p.collaboratorsOnTypebots.createMany({
|
||||
data: invitations.map((invitation) => ({
|
||||
@ -103,9 +141,54 @@ const convertInvitationsToCollaborations = async (
|
||||
userId: id,
|
||||
})),
|
||||
})
|
||||
const workspaceInvitations = invitations.reduce<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({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const joinWorkspaces = async (
|
||||
p: PrismaClient,
|
||||
{ id, email }: { id: string; email: string },
|
||||
invitations: WorkspaceInvitation[]
|
||||
) => {
|
||||
await p.memberInWorkspace.createMany({
|
||||
data: invitations.map((invitation) => ({
|
||||
workspaceId: invitation.workspaceId,
|
||||
role: invitation.type,
|
||||
userId: id,
|
||||
})),
|
||||
})
|
||||
return p.workspaceInvitation.deleteMany({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
@ -1,35 +1,48 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { Prisma } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { Credentials } from 'models'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { encrypt, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import {
|
||||
badRequest,
|
||||
encrypt,
|
||||
forbidden,
|
||||
methodNotAllowed,
|
||||
notAuthenticated,
|
||||
} from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const id = req.query.id.toString()
|
||||
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res)
|
||||
if (req.method === 'GET') {
|
||||
const credentials = await prisma.credentials.findMany({
|
||||
where: { ownerId: user.id },
|
||||
select: { name: true, type: true, ownerId: true, id: true },
|
||||
where: {
|
||||
workspace: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
},
|
||||
select: { name: true, type: true, workspaceId: true, id: true },
|
||||
})
|
||||
console.log('Hey there', credentials)
|
||||
return res.send({ credentials })
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
const data = (
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
) as Omit<Credentials, 'ownerId'>
|
||||
) as Credentials
|
||||
const { encryptedData, iv } = encrypt(data.data)
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!workspace) return forbidden(res)
|
||||
const credentials = await prisma.credentials.create({
|
||||
data: {
|
||||
...data,
|
||||
data: encryptedData,
|
||||
iv,
|
||||
ownerId: user.id,
|
||||
} as Prisma.CredentialsUncheckedCreateInput,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
return res.send({ credentials })
|
||||
}
|
@ -2,17 +2,20 @@ import { withSentry } from '@sentry/nextjs'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const id = req.query.id.toString()
|
||||
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res)
|
||||
if (req.method === 'DELETE') {
|
||||
const credentialsId = req.query.credentialsId.toString()
|
||||
const credentials = await prisma.credentials.delete({
|
||||
where: { id: credentialsId },
|
||||
const credentials = await prisma.credentials.deleteMany({
|
||||
where: {
|
||||
id: credentialsId,
|
||||
workspace: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
},
|
||||
})
|
||||
return res.send({ credentials })
|
||||
}
|
@ -4,7 +4,7 @@ import prisma from 'libs/prisma'
|
||||
import { googleSheetsScopes } from './consent-url'
|
||||
import { stringify } from 'querystring'
|
||||
import { CredentialsType } from 'models'
|
||||
import { encrypt, notAuthenticated } from 'utils'
|
||||
import { badRequest, encrypt, notAuthenticated } from 'utils'
|
||||
import { oauth2Client } from 'libs/google-sheets'
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
@ -12,11 +12,12 @@ import { getAuthenticatedUser } from 'services/api/utils'
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const { redirectUrl, stepId } = JSON.parse(
|
||||
const { redirectUrl, stepId, workspaceId } = JSON.parse(
|
||||
Buffer.from(req.query.state.toString(), 'base64').toString()
|
||||
)
|
||||
if (req.method === 'GET') {
|
||||
const code = req.query.code.toString()
|
||||
if (!workspaceId) return badRequest(res)
|
||||
if (!code)
|
||||
return res.status(400).send({ message: "Bad request, couldn't get code" })
|
||||
const { tokens } = await oauth2Client.getToken(code)
|
||||
@ -41,20 +42,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const credentials = {
|
||||
name: email,
|
||||
type: CredentialsType.GOOGLE_SHEETS,
|
||||
ownerId: user.id,
|
||||
workspaceId,
|
||||
data: encryptedData,
|
||||
iv,
|
||||
} as Prisma.CredentialsUncheckedCreateInput
|
||||
const { id: credentialsId } = await prisma.credentials.upsert({
|
||||
create: credentials,
|
||||
update: credentials,
|
||||
where: {
|
||||
name_type_ownerId: {
|
||||
name: credentials.name,
|
||||
type: credentials.type,
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
const { id: credentialsId } = await prisma.credentials.create({
|
||||
data: credentials,
|
||||
})
|
||||
const queryParams = stringify({ stepId, credentialsId })
|
||||
res.redirect(
|
||||
|
@ -1,26 +1,38 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { CustomDomain, Prisma } from 'db'
|
||||
import { CustomDomain } from 'db'
|
||||
import { got, HTTPError } from 'got'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import {
|
||||
badRequest,
|
||||
forbidden,
|
||||
methodNotAllowed,
|
||||
notAuthenticated,
|
||||
} from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const id = req.query.id.toString()
|
||||
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res)
|
||||
if (req.method === 'GET') {
|
||||
const customDomains = await prisma.customDomain.findMany({
|
||||
where: { ownerId: user.id },
|
||||
where: {
|
||||
workspace: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
},
|
||||
})
|
||||
return res.send({ customDomains })
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!workspace) return forbidden(res)
|
||||
const data = (
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
) as Omit<CustomDomain, 'ownerId'>
|
||||
) as CustomDomain
|
||||
try {
|
||||
await createDomainOnVercel(data.name)
|
||||
} catch (err) {
|
||||
@ -31,8 +43,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const customDomains = await prisma.customDomain.create({
|
||||
data: {
|
||||
...data,
|
||||
ownerId: user.id,
|
||||
} as Prisma.CustomDomainUncheckedCreateInput,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
return res.send({ customDomains })
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { got } from 'got'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const id = req.query.id.toString()
|
||||
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res)
|
||||
if (req.method === 'DELETE') {
|
||||
const domain = req.query.domain.toString()
|
||||
try {
|
||||
@ -18,6 +18,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const customDomains = await prisma.customDomain.delete({
|
||||
where: { name: domain },
|
||||
})
|
||||
console.log(
|
||||
{
|
||||
name: domain,
|
||||
workspace: { id: workspaceId },
|
||||
},
|
||||
{ some: { userId: user.id } }
|
||||
)
|
||||
await deleteDomainOnVercel(domain)
|
||||
return res.send({ customDomains })
|
||||
}
|
||||
return methodNotAllowed(res)
|
@ -3,7 +3,7 @@ import { DashboardFolder } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
@ -12,11 +12,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const parentFolderId = req.query.parentId
|
||||
? req.query.parentId.toString()
|
||||
: null
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res)
|
||||
const folders = await prisma.dashboardFolder.findMany({
|
||||
where: {
|
||||
ownerId: user.id,
|
||||
parentFolderId,
|
||||
workspace: { members: { some: { userId: user.id, workspaceId } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
@ -25,9 +28,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'POST') {
|
||||
const data = (
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
) as Pick<DashboardFolder, 'parentFolderId'>
|
||||
) as Pick<DashboardFolder, 'parentFolderId' | 'workspaceId'>
|
||||
const folder = await prisma.dashboardFolder.create({
|
||||
data: { ...data, ownerId: user.id, name: 'New folder' },
|
||||
data: { ...data, name: 'New folder' },
|
||||
})
|
||||
return res.send(folder)
|
||||
}
|
||||
|
@ -11,14 +11,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
const id = req.query.id.toString()
|
||||
if (req.method === 'GET') {
|
||||
const folder = await prisma.dashboardFolder.findUnique({
|
||||
where: { id_ownerId: { id, ownerId: user.id } },
|
||||
const folder = await prisma.dashboardFolder.findFirst({
|
||||
where: {
|
||||
id,
|
||||
workspace: { members: { some: { userId: user.id } } },
|
||||
},
|
||||
})
|
||||
return res.send({ folder })
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const folders = await prisma.dashboardFolder.delete({
|
||||
where: { id_ownerId: { id, ownerId: user.id } },
|
||||
const folders = await prisma.dashboardFolder.deleteMany({
|
||||
where: { id, workspace: { members: { some: { userId: user.id } } } },
|
||||
})
|
||||
return res.send({ folders })
|
||||
}
|
||||
@ -26,8 +29,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const data = (
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
) as Partial<DashboardFolder>
|
||||
const folders = await prisma.dashboardFolder.update({
|
||||
where: { id_ownerId: { id, ownerId: user.id } },
|
||||
const folders = await prisma.dashboardFolder.updateMany({
|
||||
where: {
|
||||
id,
|
||||
workspace: { members: { some: { userId: user.id } } },
|
||||
},
|
||||
data,
|
||||
})
|
||||
return res.send({ typebots: folders })
|
||||
|
@ -18,7 +18,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'GET') {
|
||||
const credentialsId = req.query.credentialsId as string | undefined
|
||||
if (!credentialsId) return badRequest(res)
|
||||
const spreadsheetId = req.query.id.toString()
|
||||
const spreadsheetId = req.query.id as string
|
||||
const doc = new GoogleSpreadsheet(spreadsheetId)
|
||||
const auth = await getAuthenticatedGoogleClient(user.id, credentialsId)
|
||||
if (!auth)
|
||||
|
@ -10,8 +10,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2020-08-27',
|
||||
})
|
||||
const { email, currency } =
|
||||
const { email, currency, plan, workspaceId } =
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
|
||||
console.log(plan, workspaceId)
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
success_url: `${req.headers.origin}/typebots?stripe=success`,
|
||||
cancel_url: `${req.headers.origin}/typebots?stripe=cancel`,
|
||||
@ -19,12 +21,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
allow_promotion_codes: true,
|
||||
customer_email: email,
|
||||
mode: 'subscription',
|
||||
metadata: { workspaceId, plan },
|
||||
line_items: [
|
||||
{
|
||||
price:
|
||||
currency === 'eur'
|
||||
? process.env.STRIPE_PRICE_EUR_ID
|
||||
: process.env.STRIPE_PRICE_USD_ID,
|
||||
price: getPrice(plan, currency),
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
@ -34,4 +34,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
const getPrice = (plan: 'pro' | 'team', currency: 'eur' | 'usd') => {
|
||||
if (plan === 'team')
|
||||
return currency === 'eur'
|
||||
? process.env.STRIPE_PRICE_TEAM_EUR_ID
|
||||
: process.env.STRIPE_PRICE_TEAM_USD_ID
|
||||
return currency === 'eur'
|
||||
? process.env.STRIPE_PRICE_EUR_ID
|
||||
: process.env.STRIPE_PRICE_USD_ID
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
||||
|
@ -1,25 +1,36 @@
|
||||
import { User } from 'db'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
import {
|
||||
badRequest,
|
||||
forbidden,
|
||||
methodNotAllowed,
|
||||
notAuthenticated,
|
||||
} from 'utils'
|
||||
import Stripe from 'stripe'
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import prisma from 'libs/prisma'
|
||||
import { WorkspaceRole } from 'db'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req })
|
||||
if (!session?.user)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
const user = session.user as User
|
||||
if (!user.stripeId)
|
||||
return res.status(401).json({ message: 'Not authenticated' })
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
if (req.method === 'GET') {
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res)
|
||||
if (!process.env.STRIPE_SECRET_KEY)
|
||||
throw Error('STRIPE_SECRET_KEY var is missing')
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
|
||||
},
|
||||
})
|
||||
if (!workspace?.stripeId) return forbidden(res)
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2020-08-27',
|
||||
})
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeId,
|
||||
customer: workspace.stripeId,
|
||||
return_url: req.headers.referer,
|
||||
})
|
||||
res.redirect(session.url)
|
||||
|
@ -40,36 +40,44 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const { customer_email } = session
|
||||
if (!customer_email)
|
||||
return res.status(500).send(`customer_email not found`)
|
||||
await prisma.user.update({
|
||||
where: { email: customer_email },
|
||||
data: { plan: Plan.PRO, stripeId: session.customer as string },
|
||||
const { metadata } = session
|
||||
if (!metadata?.workspaceId || !metadata?.plan)
|
||||
return res.status(500).send({ message: `customer_email not found` })
|
||||
await prisma.workspace.update({
|
||||
where: { id: metadata.workspaceId },
|
||||
data: {
|
||||
plan: metadata.plan === 'team' ? Plan.TEAM : Plan.PRO,
|
||||
stripeId: session.customer as string,
|
||||
},
|
||||
})
|
||||
return res.status(200).send({ message: 'user upgraded in DB' })
|
||||
return res.status(200).send({ message: 'workspace upgraded in DB' })
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
await prisma.user.update({
|
||||
const { metadata } = subscription
|
||||
if (!metadata.workspaceId)
|
||||
return res.status(500).send(`workspaceId not found`)
|
||||
await prisma.workspace.update({
|
||||
where: {
|
||||
stripeId: subscription.customer as string,
|
||||
id: metadata.workspaceId,
|
||||
},
|
||||
data: {
|
||||
plan: Plan.FREE,
|
||||
},
|
||||
})
|
||||
return res.status(200).send({ message: 'user downgraded in DB' })
|
||||
return res.send({ message: 'workspace downgraded in DB' })
|
||||
}
|
||||
default: {
|
||||
return res.status(304).send({ message: 'event not handled' })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error) {
|
||||
console.error(err)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
return res.status(500).send(`Error occured: ${err}`)
|
||||
}
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
|
@ -1,28 +1,30 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { Prisma } from 'db'
|
||||
import { Prisma, WorkspaceRole } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { parseNewTypebot } from 'services/typebots/typebots'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
const folderId = req.query.allFolders
|
||||
? undefined
|
||||
: req.query.folderId
|
||||
? req.query.folderId.toString()
|
||||
: null
|
||||
if (!workspaceId) return badRequest(res)
|
||||
const typebotIds = req.query.typebotIds as string[] | undefined
|
||||
if (typebotIds) {
|
||||
const typebots = await prisma.typebot.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
ownerId: user.id,
|
||||
workspace: { members: { some: { userId: user.id } } },
|
||||
id: { in: typebotIds },
|
||||
},
|
||||
{
|
||||
@ -42,8 +44,29 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
const typebots = await prisma.typebot.findMany({
|
||||
where: {
|
||||
ownerId: user.id,
|
||||
folderId,
|
||||
OR: [
|
||||
{
|
||||
folderId,
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
role: { not: WorkspaceRole.GUEST },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
members: {
|
||||
some: { userId: user.id, role: WorkspaceRole.GUEST },
|
||||
},
|
||||
},
|
||||
collaborators: { some: { userId: user.id } },
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { name: true, publishedTypebotId: true, id: true, icon: true },
|
||||
|
@ -16,26 +16,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
where: canReadTypebot(typebotId, user),
|
||||
include: {
|
||||
publishedTypebot: true,
|
||||
owner: { select: { email: true, name: true, image: true } },
|
||||
collaborators: { select: { userId: true, type: true } },
|
||||
webhooks: true,
|
||||
},
|
||||
})
|
||||
if (!typebot) return res.send({ typebot: null })
|
||||
const {
|
||||
publishedTypebot,
|
||||
owner,
|
||||
collaborators,
|
||||
webhooks,
|
||||
...restOfTypebot
|
||||
} = typebot
|
||||
const { publishedTypebot, collaborators, webhooks, ...restOfTypebot } =
|
||||
typebot
|
||||
const isReadOnly =
|
||||
collaborators.find((c) => c.userId === user.id)?.type ===
|
||||
CollaborationType.READ
|
||||
return res.send({
|
||||
typebot: restOfTypebot,
|
||||
publishedTypebot,
|
||||
owner,
|
||||
isReadOnly,
|
||||
webhooks,
|
||||
})
|
||||
|
@ -1,33 +1,28 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { canWriteTypebot } from 'services/api/dbRules'
|
||||
import { canEditGuests } from 'services/api/dbRules'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const typebotId = req.query.typebotId as string
|
||||
const userId = req.query.userId as string
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canWriteTypebot(typebotId, user),
|
||||
})
|
||||
if (!typebot) return forbidden(res)
|
||||
if (req.method === 'PUT') {
|
||||
if (req.method === 'PATCH') {
|
||||
const data = req.body
|
||||
await prisma.collaboratorsOnTypebots.upsert({
|
||||
where: { userId_typebotId: { typebotId, userId } },
|
||||
create: data,
|
||||
update: data,
|
||||
await prisma.collaboratorsOnTypebots.updateMany({
|
||||
where: { userId, typebot: canEditGuests(user, typebotId) },
|
||||
data: { type: data.type },
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
await prisma.collaboratorsOnTypebots.delete({
|
||||
where: { userId_typebotId: { typebotId, userId } },
|
||||
await prisma.collaboratorsOnTypebots.deleteMany({
|
||||
where: { userId, typebot: canEditGuests(user, typebotId) },
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { invitationToCollaborate } from 'assets/emails/invitationToCollaborate'
|
||||
import { CollaborationType } from 'db'
|
||||
import { CollaborationType, WorkspaceRole } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
|
||||
@ -30,7 +30,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canWriteTypebot(typebotId, user),
|
||||
})
|
||||
if (!typebot) return forbidden(res)
|
||||
if (!typebot || !typebot.workspaceId) return forbidden(res)
|
||||
const { email, type } =
|
||||
(req.body as
|
||||
| { email: string | undefined; type: CollaborationType | undefined }
|
||||
@ -40,7 +40,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
where: { email: email.toLowerCase() },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existingUser)
|
||||
if (existingUser) {
|
||||
await prisma.collaboratorsOnTypebots.create({
|
||||
data: {
|
||||
type,
|
||||
@ -48,7 +48,21 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
userId: existingUser.id,
|
||||
},
|
||||
})
|
||||
else
|
||||
await prisma.memberInWorkspace.upsert({
|
||||
where: {
|
||||
userId_workspaceId: {
|
||||
userId: existingUser.id,
|
||||
workspaceId: typebot.workspaceId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
role: WorkspaceRole.GUEST,
|
||||
userId: existingUser.id,
|
||||
workspaceId: typebot.workspaceId,
|
||||
},
|
||||
update: {},
|
||||
})
|
||||
} else
|
||||
await prisma.invitation.create({
|
||||
data: { email: email.toLowerCase(), type, typebotId },
|
||||
})
|
||||
|
@ -1,33 +1,32 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { Invitation } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { canWriteTypebot } from 'services/api/dbRules'
|
||||
import { canEditGuests } from 'services/api/dbRules'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const typebotId = req.query.typebotId as string
|
||||
const email = req.query.email as string
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canWriteTypebot(typebotId, user),
|
||||
})
|
||||
if (!typebot) return forbidden(res)
|
||||
if (req.method === 'PUT') {
|
||||
const data = req.body
|
||||
await prisma.invitation.upsert({
|
||||
where: { email_typebotId: { email, typebotId } },
|
||||
create: data,
|
||||
update: data,
|
||||
if (req.method === 'PATCH') {
|
||||
const data = req.body as Invitation
|
||||
await prisma.invitation.updateMany({
|
||||
where: { email, typebot: canEditGuests(user, typebotId) },
|
||||
data: { type: data.type },
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
await prisma.invitation.delete({
|
||||
where: { email_typebotId: { email, typebotId } },
|
||||
await prisma.invitation.deleteMany({
|
||||
where: {
|
||||
email,
|
||||
typebot: canEditGuests(user, typebotId),
|
||||
},
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
|
@ -3,13 +3,19 @@ import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { canReadTypebot, canWriteTypebot } from 'services/api/dbRules'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import { isFreePlan } from 'services/user/user'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
import { forbidden, methodNotAllowed, notAuthenticated } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (req.method === 'GET') {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
select: { plan: true },
|
||||
})
|
||||
if (!workspace) return forbidden(res)
|
||||
const typebotId = req.query.typebotId.toString()
|
||||
const lastResultId = req.query.lastResultId?.toString()
|
||||
const take = parseInt(req.query.limit?.toString())
|
||||
@ -24,7 +30,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
where: {
|
||||
typebot: canReadTypebot(typebotId, user),
|
||||
answers: { some: {} },
|
||||
isCompleted: isFreePlan(user) ? true : undefined,
|
||||
isCompleted: isFreePlan(workspace) ? true : undefined,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
@ -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)
|
@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
id: webhookId,
|
||||
typebot: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ workspace: { members: { some: { userId: user.id } } } },
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
@ -40,7 +40,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: data.typebotId, ownerId: user.id },
|
||||
{
|
||||
id: data.typebotId,
|
||||
workspace: { members: { some: { userId: user.id } } },
|
||||
},
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
|
35
apps/builder/pages/api/workspaces.ts
Normal file
35
apps/builder/pages/api/workspaces.ts
Normal 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)
|
28
apps/builder/pages/api/workspaces/[workspaceId].ts
Normal file
28
apps/builder/pages/api/workspaces/[workspaceId].ts
Normal 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)
|
@ -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)
|
@ -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)
|
42
apps/builder/pages/api/workspaces/[workspaceId]/members.ts
Normal file
42
apps/builder/pages/api/workspaces/[workspaceId]/members.ts
Normal 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)
|
@ -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)
|
@ -10,26 +10,32 @@ import { Spinner, useToast } from '@chakra-ui/react'
|
||||
import { pay } from 'services/stripe'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { NextPageContext } from 'next/types'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
const DashboardPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { query, isReady } = useRouter()
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'success',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const subscribe = query.subscribe?.toString()
|
||||
if (subscribe && user && user.plan === 'FREE') {
|
||||
const subscribePlan = query.subscribePlan as 'pro' | 'team' | undefined
|
||||
if (workspace && subscribePlan && user && user.plan === 'FREE') {
|
||||
setIsLoading(true)
|
||||
pay(
|
||||
pay({
|
||||
user,
|
||||
navigator.languages.find((l) => l.includes('fr')) ? 'eur' : 'usd'
|
||||
)
|
||||
plan: subscribePlan,
|
||||
workspaceId: workspace.id,
|
||||
currency: navigator.languages.find((l) => l.includes('fr'))
|
||||
? 'eur'
|
||||
: 'usd',
|
||||
})
|
||||
}
|
||||
}, [query.subscribe, user])
|
||||
}, [query, user, workspace])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) return
|
||||
@ -38,7 +44,7 @@ const DashboardPage = () => {
|
||||
|
||||
if (stripeStatus === 'success')
|
||||
toast({
|
||||
title: 'Typebot Pro',
|
||||
title: 'Payment successful',
|
||||
description: "You've successfully subscribed 🎉",
|
||||
})
|
||||
if (couponCode) {
|
||||
|
@ -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
|
@ -11,18 +11,36 @@ import {
|
||||
CollaborationType,
|
||||
DashboardFolder,
|
||||
GraphNavigation,
|
||||
Plan,
|
||||
PrismaClient,
|
||||
User,
|
||||
WorkspaceRole,
|
||||
} from 'db'
|
||||
import { readFileSync } from 'fs'
|
||||
import { encrypt } from 'utils'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const proWorkspaceId = 'proWorkspace'
|
||||
export const freeWorkspaceId = 'freeWorkspace'
|
||||
export const sharedWorkspaceId = 'sharedWorkspace'
|
||||
export const guestWorkspaceId = 'guestWorkspace'
|
||||
|
||||
export const teardownDatabase = async () => {
|
||||
const ownerFilter = {
|
||||
where: { ownerId: { in: ['freeUser', 'proUser'] } },
|
||||
where: {
|
||||
workspace: {
|
||||
members: { some: { userId: { in: ['freeUser', 'proUser'] } } },
|
||||
},
|
||||
},
|
||||
}
|
||||
await prisma.workspace.deleteMany({
|
||||
where: {
|
||||
members: {
|
||||
some: { userId: { in: ['freeUser', 'proUser'] } },
|
||||
},
|
||||
},
|
||||
})
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: ['freeUser', 'proUser'] } },
|
||||
})
|
||||
@ -37,23 +55,75 @@ export const setupDatabase = async () => {
|
||||
return createCredentials()
|
||||
}
|
||||
|
||||
export const createUsers = () =>
|
||||
prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: 'freeUser',
|
||||
email: 'free-user@email.com',
|
||||
name: 'Free user',
|
||||
graphNavigation: GraphNavigation.TRACKPAD,
|
||||
export const createUsers = async () => {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: 'proUser',
|
||||
email: 'pro-user@email.com',
|
||||
name: 'Pro user',
|
||||
graphNavigation: GraphNavigation.TRACKPAD,
|
||||
workspaces: {
|
||||
create: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
workspace: {
|
||||
create: {
|
||||
id: proWorkspaceId,
|
||||
name: "Pro user's workspace",
|
||||
plan: Plan.TEAM,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'proUser',
|
||||
email: 'pro-user@email.com',
|
||||
name: 'Pro user',
|
||||
graphNavigation: GraphNavigation.TRACKPAD,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: 'freeUser',
|
||||
email: 'free-user@email.com',
|
||||
name: 'Free user',
|
||||
graphNavigation: GraphNavigation.TRACKPAD,
|
||||
workspaces: {
|
||||
create: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
workspace: {
|
||||
create: {
|
||||
id: freeWorkspaceId,
|
||||
name: "Free user's workspace",
|
||||
plan: Plan.FREE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await prisma.workspace.create({
|
||||
data: {
|
||||
id: 'free',
|
||||
name: 'Free workspace',
|
||||
plan: Plan.FREE,
|
||||
members: {
|
||||
createMany: {
|
||||
data: [{ role: WorkspaceRole.ADMIN, userId: 'proUser' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return prisma.workspace.create({
|
||||
data: {
|
||||
id: sharedWorkspaceId,
|
||||
name: 'Shared Workspace',
|
||||
plan: Plan.TEAM,
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{ role: WorkspaceRole.MEMBER, userId: 'proUser' },
|
||||
{ role: WorkspaceRole.ADMIN, userId: 'freeUser' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const createWebhook = async (
|
||||
typebotId: string,
|
||||
@ -91,7 +161,7 @@ export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
|
||||
export const createFolders = (partialFolders: Partial<DashboardFolder>[]) =>
|
||||
prisma.dashboardFolder.createMany({
|
||||
data: partialFolders.map((folder) => ({
|
||||
ownerId: 'proUser',
|
||||
workspaceId: proWorkspaceId,
|
||||
name: 'Folder #1',
|
||||
...folder,
|
||||
})),
|
||||
@ -110,9 +180,9 @@ const createCredentials = () => {
|
||||
data: [
|
||||
{
|
||||
name: 'pro-user@email.com',
|
||||
ownerId: 'proUser',
|
||||
type: CredentialsType.GOOGLE_SHEETS,
|
||||
data: encryptedData,
|
||||
workspaceId: proWorkspaceId,
|
||||
iv,
|
||||
},
|
||||
],
|
||||
@ -179,9 +249,10 @@ const parseTypebotToPublicTypebot = (
|
||||
|
||||
const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
|
||||
id: partialTypebot.id ?? 'typebot',
|
||||
ownerId: 'proUser',
|
||||
workspaceId: proWorkspaceId,
|
||||
folderId: null,
|
||||
name: 'My typebot',
|
||||
ownerId: 'proUser',
|
||||
theme: defaultTheme,
|
||||
settings: defaultSettings,
|
||||
publicId: null,
|
||||
@ -243,8 +314,9 @@ export const importTypebotInDatabase = async (
|
||||
) => {
|
||||
const typebot: any = {
|
||||
...JSON.parse(readFileSync(path).toString()),
|
||||
...updates,
|
||||
workspaceId: proWorkspaceId,
|
||||
ownerId: 'proUser',
|
||||
...updates,
|
||||
}
|
||||
await prisma.typebot.create({
|
||||
data: typebot,
|
||||
|
28
apps/builder/playwright/tests/accountSettings.spec.ts
Normal file
28
apps/builder/playwright/tests/accountSettings.spec.ts
Normal 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()
|
||||
})
|
@ -1,35 +1,41 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import cuid from 'cuid'
|
||||
import { CollaborationType, Plan, WorkspaceRole } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { InputStepType, defaultTextInputOptions } from 'models'
|
||||
import path from 'path'
|
||||
import {
|
||||
createResults,
|
||||
createTypebots,
|
||||
parseDefaultBlockWithStep,
|
||||
} from '../services/database'
|
||||
|
||||
const typebotId = cuid()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
name: 'Shared typebot',
|
||||
ownerId: 'freeUser',
|
||||
...parseDefaultBlockWithStep({
|
||||
type: InputStepType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
await createResults({ typebotId })
|
||||
})
|
||||
|
||||
test.describe('Typebot owner', () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test('Can invite collaborators', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
const guestWorkspaceId = cuid()
|
||||
await prisma.workspace.create({
|
||||
data: {
|
||||
id: guestWorkspaceId,
|
||||
name: 'Guest Workspace',
|
||||
plan: Plan.FREE,
|
||||
members: {
|
||||
createMany: {
|
||||
data: [{ role: WorkspaceRole.ADMIN, userId: 'proUser' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
name: 'Guest typebot',
|
||||
workspaceId: guestWorkspaceId,
|
||||
...parseDefaultBlockWithStep({
|
||||
type: InputStepType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('button[aria-label="Show collaboration menu"]')
|
||||
await expect(page.locator('text=Free user')).toBeHidden()
|
||||
@ -44,13 +50,12 @@ test.describe('Typebot owner', () => {
|
||||
await expect(page.locator('text=Free user')).toBeHidden()
|
||||
await page.fill(
|
||||
'input[placeholder="colleague@company.com"]',
|
||||
'pro-user@email.com'
|
||||
'free-user@email.com'
|
||||
)
|
||||
await page.click('text=Can edit')
|
||||
await page.click('text=Can view')
|
||||
await page.click('text=Invite')
|
||||
await expect(page.locator('text=Free user')).toBeVisible()
|
||||
await expect(page.locator('text=Pro user')).toBeVisible()
|
||||
await page.click('text="guest@email.com"')
|
||||
await page.click('text="Remove"')
|
||||
await expect(page.locator('text="guest@email.com"')).toBeHidden()
|
||||
@ -59,17 +64,47 @@ test.describe('Typebot owner', () => {
|
||||
|
||||
test.describe('Collaborator', () => {
|
||||
test('should display shared typebots', async ({ page }) => {
|
||||
await page.goto('/typebots')
|
||||
await expect(page.locator('text=Shared')).toBeVisible()
|
||||
await page.click('text=Shared')
|
||||
await page.waitForNavigation()
|
||||
expect(page.url()).toMatch('/typebots/shared')
|
||||
await expect(page.locator('text="Shared typebot"')).toBeVisible()
|
||||
await page.click('text=Shared typebot')
|
||||
const typebotId = cuid()
|
||||
const guestWorkspaceId = cuid()
|
||||
await prisma.workspace.create({
|
||||
data: {
|
||||
id: guestWorkspaceId,
|
||||
name: 'Guest Workspace #2',
|
||||
plan: Plan.FREE,
|
||||
members: {
|
||||
createMany: {
|
||||
data: [{ role: WorkspaceRole.GUEST, userId: 'proUser' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
name: 'Guest typebot',
|
||||
workspaceId: guestWorkspaceId,
|
||||
...parseDefaultBlockWithStep({
|
||||
type: InputStepType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
await prisma.collaboratorsOnTypebots.create({
|
||||
data: {
|
||||
typebotId,
|
||||
userId: 'proUser',
|
||||
type: CollaborationType.READ,
|
||||
},
|
||||
})
|
||||
await createResults({ typebotId })
|
||||
await page.goto(`/typebots`)
|
||||
await page.click("text=Pro user's workspace")
|
||||
await page.click('text=Guest workspace #2')
|
||||
await page.click('text=Guest typebot')
|
||||
await page.click('button[aria-label="Show collaboration menu"]')
|
||||
await page.click('text=Pro user')
|
||||
await page.click('text=Everyone at Guest workspace')
|
||||
await expect(page.locator('text="Remove"')).toBeHidden()
|
||||
await expect(page.locator('text=Free user')).toBeVisible()
|
||||
await expect(page.locator('text=Pro user')).toBeVisible()
|
||||
await page.click('text=Block #1', { force: true })
|
||||
await expect(page.locator('input[value="Block #1"]')).toBeHidden()
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
|
@ -1,63 +1,67 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { InputStepType, defaultTextInputOptions } from 'models'
|
||||
import { createTypebots, parseDefaultBlockWithStep } from '../services/database'
|
||||
import {
|
||||
createTypebots,
|
||||
freeWorkspaceId,
|
||||
parseDefaultBlockWithStep,
|
||||
} from '../services/database'
|
||||
import path from 'path'
|
||||
import cuid from 'cuid'
|
||||
|
||||
const typebotId = cuid()
|
||||
test.describe('Dashboard page', () => {
|
||||
test('should be able to connect custom domain', async ({ page }) => {
|
||||
test('should be able to connect custom domain', async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
...parseDefaultBlockWithStep({
|
||||
type: InputStepType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
await page.goto(`/typebots/${typebotId}/share`)
|
||||
await page.click('text=Add my domain')
|
||||
await page.click('text=Connect new')
|
||||
await page.fill('input[placeholder="bot.my-domain.com"]', 'test')
|
||||
await expect(page.locator('text=Save')).toBeDisabled()
|
||||
await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com')
|
||||
await expect(page.locator('text="A"')).toBeVisible()
|
||||
await page.fill('input[placeholder="bot.my-domain.com"]', 'sub.yolozeeer.com')
|
||||
await expect(page.locator('text="CNAME"')).toBeVisible()
|
||||
await page.click('text=Save')
|
||||
await expect(page.locator('text="https://sub.yolozeeer.com/"')).toBeVisible()
|
||||
await page.click('text="Edit" >> nth=1')
|
||||
await page.fill('text=https://sub.yolozeeer.com/Copy >> input', 'custom-path')
|
||||
await page.press(
|
||||
'text=https://sub.yolozeeer.com/custom-path >> input',
|
||||
'Enter'
|
||||
)
|
||||
await expect(page.locator('text="custom-path"')).toBeVisible()
|
||||
await page.click('[aria-label="Remove custom domain"]')
|
||||
await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden()
|
||||
await page.click('button >> text=Add my domain')
|
||||
await page.click('[aria-label="Remove domain"]')
|
||||
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Free workspace', () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test("Add my domain shouldn't be available", async ({ page }) => {
|
||||
const typebotId = cuid()
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
workspaceId: freeWorkspaceId,
|
||||
...parseDefaultBlockWithStep({
|
||||
type: InputStepType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/share`)
|
||||
await page.click('text=Add my domain')
|
||||
await page.click('text=Connect new')
|
||||
await page.fill('input[placeholder="bot.my-domain.com"]', 'test')
|
||||
await expect(page.locator('text=Save')).toBeDisabled()
|
||||
await page.fill('input[placeholder="bot.my-domain.com"]', 'yolozeeer.com')
|
||||
await expect(page.locator('text="A"')).toBeVisible()
|
||||
await page.fill(
|
||||
'input[placeholder="bot.my-domain.com"]',
|
||||
'sub.yolozeeer.com'
|
||||
)
|
||||
await expect(page.locator('text="CNAME"')).toBeVisible()
|
||||
await page.click('text=Save')
|
||||
await expect(
|
||||
page.locator('text="https://sub.yolozeeer.com/"')
|
||||
).toBeVisible()
|
||||
await page.click('text="Edit" >> nth=1')
|
||||
await page.fill(
|
||||
'text=https://sub.yolozeeer.com/Copy >> input',
|
||||
'custom-path'
|
||||
)
|
||||
await page.press(
|
||||
'text=https://sub.yolozeeer.com/custom-path >> input',
|
||||
'Enter'
|
||||
)
|
||||
await expect(page.locator('text="custom-path"')).toBeVisible()
|
||||
await page.click('[aria-label="Remove custom domain"]')
|
||||
await expect(page.locator('text=sub.yolozeeer.com')).toBeHidden()
|
||||
await page.click('button >> text=Add my domain')
|
||||
await page.click('[aria-label="Remove domain"]')
|
||||
await expect(page.locator('[aria-label="Remove domain"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Free user', () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test("Add my domain shouldn't be available", async ({ page }) => {
|
||||
await page.goto(`/typebots/${typebotId}/share`)
|
||||
await page.click('text=Add my domain')
|
||||
await expect(page.locator('text=Upgrade now')).toBeVisible()
|
||||
})
|
||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
@ -80,11 +80,10 @@ test.describe('Dashboard page', () => {
|
||||
})
|
||||
test("create folder shouldn't be available", async ({ page }) => {
|
||||
await page.goto('/typebots')
|
||||
await page.click('text=Shared workspace')
|
||||
await page.click('text=Free workspace')
|
||||
await page.click('text=Create a folder')
|
||||
await expect(
|
||||
page.locator('text="You can\'t create folders with the basic plan"')
|
||||
).toBeVisible()
|
||||
await expect(page.locator('text=Upgrade now')).toBeVisible()
|
||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -1,6 +1,7 @@
|
||||
import test, { expect, Page } from '@playwright/test'
|
||||
import cuid from 'cuid'
|
||||
import { readFileSync } from 'fs'
|
||||
import prisma from 'libs/prisma'
|
||||
import { defaultTextInputOptions, InputStepType } from 'models'
|
||||
import { parse } from 'papaparse'
|
||||
import path from 'path'
|
||||
@ -113,14 +114,18 @@ test.describe('Results page', () => {
|
||||
validateExportAll(dataAll)
|
||||
})
|
||||
|
||||
test.describe('Free user', () => {
|
||||
test.describe('Free user', async () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test("Incomplete results shouldn't be displayed", async ({ page }) => {
|
||||
await prisma.typebot.update({
|
||||
where: { id: typebotId },
|
||||
data: { workspaceId: 'free' },
|
||||
})
|
||||
await page.goto(`/typebots/${typebotId}/results`)
|
||||
await page.click('text=Unlock')
|
||||
await expect(page.locator('text=Upgrade now')).toBeVisible()
|
||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -124,13 +124,14 @@ test.describe.parallel('Settings page', () => {
|
||||
path.join(__dirname, '../fixtures/typebots/settings.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
workspaceId: 'free',
|
||||
}
|
||||
)
|
||||
await page.goto(`/typebots/${typebotId}/settings`)
|
||||
await page.click('button:has-text("General")')
|
||||
await expect(page.locator('text=Pro')).toBeVisible()
|
||||
await page.click('text=Typebot.io branding')
|
||||
await expect(page.locator('text=Upgrade now')).toBeVisible()
|
||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
134
apps/builder/playwright/tests/workspaces.spec.ts
Normal file
134
apps/builder/playwright/tests/workspaces.spec.ts
Normal 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()
|
||||
})
|
@ -1,4 +1,4 @@
|
||||
import { CollaborationType, Prisma, User } from 'db'
|
||||
import { CollaborationType, Prisma, User, WorkspaceRole } from 'db'
|
||||
|
||||
const parseWhereFilter = (
|
||||
typebotIds: string[] | string,
|
||||
@ -6,14 +6,6 @@ const parseWhereFilter = (
|
||||
type: 'read' | 'write'
|
||||
): Prisma.TypebotWhereInput => ({
|
||||
OR: [
|
||||
{
|
||||
id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
|
||||
ownerId:
|
||||
(type === 'read' && user.email === process.env.ADMIN_EMAIL) ||
|
||||
process.env.NEXT_PUBLIC_E2E_TEST
|
||||
? undefined
|
||||
: user.id,
|
||||
},
|
||||
{
|
||||
id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
|
||||
collaborators: {
|
||||
@ -23,6 +15,18 @@ const parseWhereFilter = (
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
|
||||
workspace:
|
||||
(type === 'read' && user.email === process.env.ADMIN_EMAIL) ||
|
||||
process.env.NEXT_PUBLIC_E2E_TEST
|
||||
? undefined
|
||||
: {
|
||||
members: {
|
||||
some: { userId: user.id, role: { not: WorkspaceRole.GUEST } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@ -37,3 +41,12 @@ export const canReadTypebots = (typebotIds: string[], user: User) =>
|
||||
|
||||
export const canWriteTypebots = (typebotIds: string[], user: User) =>
|
||||
parseWhereFilter(typebotIds, user, 'write')
|
||||
|
||||
export const canEditGuests = (user: User, typebotId: string) => ({
|
||||
id: typebotId,
|
||||
workspace: {
|
||||
members: {
|
||||
some: { userId: user.id, role: { not: WorkspaceRole.GUEST } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -1,17 +1,18 @@
|
||||
import { Credentials } from 'models'
|
||||
import { stringify } from 'qs'
|
||||
import useSWR from 'swr'
|
||||
import { sendRequest } from 'utils'
|
||||
import { fetcher } from '../utils'
|
||||
import { fetcher } from './utils'
|
||||
|
||||
export const useCredentials = ({
|
||||
userId,
|
||||
workspaceId,
|
||||
onError,
|
||||
}: {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
onError?: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>(
|
||||
userId ? `/api/users/${userId}/credentials` : null,
|
||||
workspaceId ? `/api/credentials?${stringify({ workspaceId })}` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error && onError) onError(error)
|
||||
@ -23,24 +24,25 @@ export const useCredentials = ({
|
||||
}
|
||||
|
||||
export const createCredentials = async (
|
||||
userId: string,
|
||||
credentials: Omit<Credentials, 'ownerId' | 'id' | 'iv' | 'createdAt'>
|
||||
credentials: Omit<Credentials, 'id' | 'iv' | 'createdAt' | 'ownerId'>
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/users/${userId}/credentials`,
|
||||
url: `/api/credentials?${stringify({
|
||||
workspaceId: credentials.workspaceId,
|
||||
})}`,
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
})
|
||||
|
||||
export const deleteCredentials = async (
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
credentialsId: string
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/users/${userId}/credentials/${credentialsId}`,
|
||||
url: `/api/credentials/${credentialsId}?${stringify({ workspaceId })}`,
|
||||
method: 'DELETE',
|
||||
})
|
@ -1,20 +1,24 @@
|
||||
import { CustomDomain } from 'db'
|
||||
import { Credentials } from 'models'
|
||||
import { stringify } from 'qs'
|
||||
import useSWR from 'swr'
|
||||
import { sendRequest } from 'utils'
|
||||
import { fetcher } from '../utils'
|
||||
import { fetcher } from './utils'
|
||||
|
||||
export const useCustomDomains = ({
|
||||
userId,
|
||||
workspaceId,
|
||||
onError,
|
||||
}: {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ customDomains: Omit<CustomDomain, 'createdAt'>[] },
|
||||
{ customDomains: Omit<CustomDomain, 'createdAt' | 'ownerId'>[] },
|
||||
Error
|
||||
>(userId ? `/api/users/${userId}/customDomains` : null, fetcher)
|
||||
>(
|
||||
workspaceId ? `/api/customDomains?${stringify({ workspaceId })}` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
customDomains: data?.customDomains,
|
||||
@ -24,24 +28,24 @@ export const useCustomDomains = ({
|
||||
}
|
||||
|
||||
export const createCustomDomain = async (
|
||||
userId: string,
|
||||
customDomain: Omit<CustomDomain, 'ownerId' | 'createdAt'>
|
||||
workspaceId: string,
|
||||
customDomain: Omit<CustomDomain, 'createdAt' | 'workspaceId' | 'ownerId'>
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/users/${userId}/customDomains`,
|
||||
url: `/api/customDomains?${stringify({ workspaceId })}`,
|
||||
method: 'POST',
|
||||
body: customDomain,
|
||||
})
|
||||
|
||||
export const deleteCustomDomain = async (
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
customDomain: string
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/users/${userId}/customDomains/${customDomain}`,
|
||||
url: `/api/customDomains/${customDomain}?${stringify({ workspaceId })}`,
|
||||
method: 'DELETE',
|
||||
})
|
@ -6,14 +6,16 @@ import { sendRequest } from 'utils'
|
||||
|
||||
export const useFolders = ({
|
||||
parentId,
|
||||
workspaceId,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId?: string
|
||||
parentId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const params = stringify({ parentId })
|
||||
const params = stringify({ parentId, workspaceId })
|
||||
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
|
||||
`/api/folders?${params}`,
|
||||
workspaceId ? `/api/folders?${params}` : null,
|
||||
fetcher,
|
||||
{ dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined }
|
||||
)
|
||||
@ -45,12 +47,13 @@ export const useFolderContent = ({
|
||||
}
|
||||
|
||||
export const createFolder = async (
|
||||
workspaceId: string,
|
||||
folder: Pick<DashboardFolder, 'parentFolderId'>
|
||||
) =>
|
||||
sendRequest<DashboardFolder>({
|
||||
url: `/api/folders`,
|
||||
method: 'POST',
|
||||
body: folder,
|
||||
body: { ...folder, workspaceId },
|
||||
})
|
||||
|
||||
export const deleteFolder = async (id: string) =>
|
||||
|
@ -11,9 +11,10 @@ import {
|
||||
|
||||
export const getGoogleSheetsConsentScreenUrl = (
|
||||
redirectUrl: string,
|
||||
stepId: string
|
||||
stepId: string,
|
||||
workspaceId?: string
|
||||
) => {
|
||||
const queryParams = stringify({ redirectUrl, stepId })
|
||||
const queryParams = stringify({ redirectUrl, stepId, workspaceId })
|
||||
return `/api/credentials/google-sheets/consent-url?${queryParams}`
|
||||
}
|
||||
|
||||
|
@ -38,6 +38,7 @@ export const parsePublicTypebotToTypebot = (
|
||||
folderId: existingTypebot.folderId,
|
||||
ownerId: existingTypebot.ownerId,
|
||||
icon: existingTypebot.icon,
|
||||
workspaceId: existingTypebot.workspaceId,
|
||||
})
|
||||
|
||||
export const createPublishedTypebot = async (typebot: PublicTypebot) =>
|
||||
|
@ -2,14 +2,21 @@ import { User } from 'db'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const pay = async (user: User, currency: 'usd' | 'eur') => {
|
||||
type Props = {
|
||||
user: User
|
||||
currency: 'usd' | 'eur'
|
||||
plan: 'pro' | 'team'
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export const pay = async ({ user, currency, plan, workspaceId }: Props) => {
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
|
||||
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
|
||||
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY)
|
||||
const { data, error } = await sendRequest<{ sessionId: string }>({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/checkout',
|
||||
body: { email: user.email, currency },
|
||||
body: { email: user.email, currency, plan, workspaceId },
|
||||
})
|
||||
if (error || !data) return
|
||||
return stripe?.redirectToCheckout({
|
||||
|
@ -36,7 +36,7 @@ export const updateCollaborator = (
|
||||
collaborator: CollaboratorsOnTypebots
|
||||
) =>
|
||||
sendRequest({
|
||||
method: 'PUT',
|
||||
method: 'PATCH',
|
||||
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
|
||||
body: collaborator,
|
||||
})
|
||||
|
@ -36,10 +36,10 @@ export const sendInvitation = (
|
||||
export const updateInvitation = (
|
||||
typebotId: string,
|
||||
email: string,
|
||||
invitation: Omit<Invitation, 'createdAt'>
|
||||
invitation: Omit<Invitation, 'createdAt' | 'id'>
|
||||
) =>
|
||||
sendRequest({
|
||||
method: 'PUT',
|
||||
method: 'PATCH',
|
||||
url: `/api/typebots/${typebotId}/invitations/${email}`,
|
||||
body: invitation,
|
||||
})
|
||||
|
@ -64,18 +64,20 @@ export type TypebotInDashboard = Pick<
|
||||
>
|
||||
export const useTypebots = ({
|
||||
folderId,
|
||||
workspaceId,
|
||||
allFolders,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId?: string
|
||||
folderId?: string
|
||||
allFolders?: boolean
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const params = stringify({ folderId, allFolders })
|
||||
const params = stringify({ folderId, allFolders, workspaceId })
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ typebots: TypebotInDashboard[] },
|
||||
Error
|
||||
>(`/api/typebots?${params}`, fetcher, {
|
||||
>(workspaceId ? `/api/typebots?${params}` : null, fetcher, {
|
||||
dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
|
||||
})
|
||||
if (error) onError(error)
|
||||
@ -88,10 +90,12 @@ export const useTypebots = ({
|
||||
|
||||
export const createTypebot = async ({
|
||||
folderId,
|
||||
}: Pick<Typebot, 'folderId'>) => {
|
||||
workspaceId,
|
||||
}: Pick<Typebot, 'folderId' | 'workspaceId'>) => {
|
||||
const typebot = {
|
||||
folderId,
|
||||
name: 'My typebot',
|
||||
workspaceId,
|
||||
}
|
||||
return sendRequest<Typebot>({
|
||||
url: `/api/typebots`,
|
||||
@ -379,13 +383,13 @@ export const parseDefaultPublicId = (name: string, id: string) =>
|
||||
toKebabCase(name) + `-${id?.slice(-7)}`
|
||||
|
||||
export const parseNewTypebot = ({
|
||||
ownerId,
|
||||
folderId,
|
||||
name,
|
||||
ownerAvatarUrl,
|
||||
workspaceId,
|
||||
}: {
|
||||
ownerId: string
|
||||
folderId: string | null
|
||||
workspaceId: string
|
||||
name: string
|
||||
ownerAvatarUrl?: string
|
||||
}): Omit<
|
||||
@ -413,9 +417,10 @@ export const parseNewTypebot = ({
|
||||
steps: [startStep],
|
||||
}
|
||||
return {
|
||||
ownerId: null,
|
||||
folderId,
|
||||
name,
|
||||
ownerId,
|
||||
workspaceId,
|
||||
blocks: [startBlock],
|
||||
edges: [],
|
||||
variables: [],
|
||||
|
@ -1,3 +1,3 @@
|
||||
export * from './user'
|
||||
export * from './customDomains'
|
||||
export * from './credentials'
|
||||
export * from '../customDomains'
|
||||
export * from '../credentials'
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user